diff --git a/Directory.Packages.props b/Directory.Packages.props index 63833ad..2190654 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,8 +16,9 @@ + - + \ No newline at end of file diff --git a/Voxen-Server.slnx b/Voxen-Server.slnx index 9291968..1ed1998 100644 --- a/Voxen-Server.slnx +++ b/Voxen-Server.slnx @@ -19,6 +19,7 @@ + diff --git a/src/bundles/Voxen.Server/Program.cs b/src/bundles/Voxen.Server/Program.cs index c533fbe..b86b1d5 100644 --- a/src/bundles/Voxen.Server/Program.cs +++ b/src/bundles/Voxen.Server/Program.cs @@ -1,5 +1,6 @@ using FastEndpoints; using FastEndpoints.Swagger; +using Voxen.Server.Audits.Extensions; using Voxen.Server.Authentication.Extensions; using Voxen.Server.Channels.Extensions; using Voxen.Server.Domain.Extensions; @@ -14,6 +15,7 @@ var jwtSettings = builder.Configuration.GetSection("Jwt"); builder.Services + .AddVoxenAudits() .AddVoxenApiServices() .AddVoxenAuthentication(jwtSettings) .AddVoxenChannels() diff --git a/src/bundles/Voxen.Server/Voxen.Server.csproj b/src/bundles/Voxen.Server/Voxen.Server.csproj index 3e7ae42..c351cdd 100644 --- a/src/bundles/Voxen.Server/Voxen.Server.csproj +++ b/src/bundles/Voxen.Server/Voxen.Server.csproj @@ -8,6 +8,7 @@ + diff --git a/src/modules/Voxen.Server.Audits/Endpoints/GetAuditLogs/GetAuditLogsEndpoint.cs b/src/modules/Voxen.Server.Audits/Endpoints/GetAuditLogs/GetAuditLogsEndpoint.cs new file mode 100644 index 0000000..cace929 --- /dev/null +++ b/src/modules/Voxen.Server.Audits/Endpoints/GetAuditLogs/GetAuditLogsEndpoint.cs @@ -0,0 +1,46 @@ +using FastEndpoints; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; +using Voxen.Server.Audits.Models; +using Voxen.Server.Domain; +using Voxen.Server.Domain.Enums; + +namespace Voxen.Server.Audits.Endpoints.GetAuditLogs; + +/// +/// An endpoint for retrieving audit logs. +/// +public sealed class GetAuditLogsEndpoint(VoxenDbContext db) : EndpointWithoutRequest> +{ + /// + public override void Configure() + { + Get("/audits"); + Roles(nameof(ServerRole.Admin)); + } + + /// + public override async Task HandleAsync(CancellationToken ct) + { + var logs = await db.AuditLogs + .AsNoTracking() + .Include(a => a.User) + .OrderByDescending(a => a.CreatedAt) + .Select(a => new GetAuditLogsResponse + { + Id = a.Id, + UserId = a.UserId, + UserName = a.User.UserName, + Action = a.Action, + Category = a.Category, + EntityId = a.EntityId, + Changes = string.IsNullOrWhiteSpace(a.ChangesJson) + ? null + : JsonSerializer.Deserialize>(a.ChangesJson), + CreatedAt = a.CreatedAt + }) + .ToListAsync(ct); + + await Send.OkAsync(logs, ct); + } +} diff --git a/src/modules/Voxen.Server.Audits/Endpoints/GetAuditLogs/GetAuditLogsResponse.cs b/src/modules/Voxen.Server.Audits/Endpoints/GetAuditLogs/GetAuditLogsResponse.cs new file mode 100644 index 0000000..a7a36e1 --- /dev/null +++ b/src/modules/Voxen.Server.Audits/Endpoints/GetAuditLogs/GetAuditLogsResponse.cs @@ -0,0 +1,50 @@ +using Voxen.Server.Audits.Models; +using Voxen.Server.Domain.Enums; + +namespace Voxen.Server.Audits.Endpoints.GetAuditLogs; + +/// +/// Represents the response structure for audit log data. +/// +public class GetAuditLogsResponse +{ + /// + /// Gets or sets the unique identifier for the audit log. + /// + public Guid Id { get; set; } + + /// + /// Gets or sets the identifier of the user associated with the audit log. + /// + public Guid UserId { get; set; } + + /// + /// Gets or sets the name of the user associated with the audit log. + /// + public string? UserName { get; set; } + + /// + /// Gets or sets the type of action performed in the audit log. + /// + public AuditAction Action { get; set; } + + /// + /// Gets or sets the category of the action logged in the audit log. + /// + public AuditCategory Category { get; set; } + + /// + /// Gets or sets the identifier of the entity affected by the audit log. + /// + public Guid? EntityId { get; set; } + + /// + /// Gets or sets the deserialized list of changes (old/new values) for this audit log. + /// + public List? Changes { get; set; } + + /// + /// Gets or sets the timestamp when the audit log was created. + /// + public DateTime CreatedAt { get; set; } +} diff --git a/src/modules/Voxen.Server.Audits/Extensions/ServiceCollectionExtensions.cs b/src/modules/Voxen.Server.Audits/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..bd63775 --- /dev/null +++ b/src/modules/Voxen.Server.Audits/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.DependencyInjection; +using Voxen.Server.Audits.Interfaces; +using Voxen.Server.Audits.Services; + +namespace Voxen.Server.Audits.Extensions; + +/// +/// Provides extension methods for configuring Voxen Audits services in an . +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds the Voxen Audits services to the specified . + /// + public static IServiceCollection AddVoxenAudits(this IServiceCollection services) + { + services.AddScoped(); + + return services; + } +} diff --git a/src/modules/Voxen.Server.Audits/Interfaces/IAuditLogService.cs b/src/modules/Voxen.Server.Audits/Interfaces/IAuditLogService.cs new file mode 100644 index 0000000..11b2178 --- /dev/null +++ b/src/modules/Voxen.Server.Audits/Interfaces/IAuditLogService.cs @@ -0,0 +1,22 @@ +using Voxen.Server.Audits.Models; +using Voxen.Server.Domain.Entities; +using Voxen.Server.Domain.Enums; + +namespace Voxen.Server.Audits.Interfaces; + +/// +/// Defines the contract for an audit log service that handles logging of audit entries. +/// +public interface IAuditLogService +{ + /// + /// Logs an audit entry to the database. + /// + /// The user who performed the action. + /// The type of action being logged. + /// The category of the action being logged. + /// The unique identifier of the entity associated with the audit log. + /// A collection of changes made to the entity, represented as objects. + /// A cancellation token used to cancel the asynchronous action if needed. + public Task LogAsync(User actor, AuditAction action, AuditCategory category, Guid entityId, IEnumerable changes, CancellationToken ct = default); +} diff --git a/src/modules/Voxen.Server.Audits/Models/AuditChange.cs b/src/modules/Voxen.Server.Audits/Models/AuditChange.cs new file mode 100644 index 0000000..60345e2 --- /dev/null +++ b/src/modules/Voxen.Server.Audits/Models/AuditChange.cs @@ -0,0 +1,22 @@ +namespace Voxen.Server.Audits.Models; + +/// +/// Represents a change made to an entity during an audit. +/// +public class AuditChange +{ + /// + /// Gets or sets the name of the property that was changed during the audit. + /// + public string PropertyName { get; set; } = null!; + + /// + /// Gets or sets the previous value of the property before the change was made. + /// + public string? OldValue { get; set; } + + /// + /// Gets or sets the new value of the property after the change. + /// + public string NewValue { get; set; } = null!; +} diff --git a/src/modules/Voxen.Server.Audits/Services/AuditLogService.cs b/src/modules/Voxen.Server.Audits/Services/AuditLogService.cs new file mode 100644 index 0000000..4ac32b4 --- /dev/null +++ b/src/modules/Voxen.Server.Audits/Services/AuditLogService.cs @@ -0,0 +1,40 @@ +using System.Text.Json; +using Voxen.Server.Audits.Interfaces; +using Voxen.Server.Audits.Models; +using Voxen.Server.Domain; +using Voxen.Server.Domain.Entities; +using Voxen.Server.Domain.Enums; + +namespace Voxen.Server.Audits.Services; + +/// +/// Provides functionality for logging audit entries to the database. +/// +public class AuditLogService(VoxenDbContext db) : IAuditLogService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + /// + public async Task LogAsync(User actor, AuditAction action, AuditCategory category, Guid entityId, IEnumerable changes, CancellationToken ct = default) + { + var changeList = changes.ToList(); + var audit = new Audit + { + UserId = actor.Id, + Action = action, + Category = category, + EntityId = entityId, + ChangesJson = changeList is { Count: > 0 } + ? JsonSerializer.Serialize(changeList, JsonOptions) + : null, + CreatedAt = DateTime.UtcNow + }; + + db.AuditLogs.Add(audit); + await db.SaveChangesAsync(ct); + } +} diff --git a/src/modules/Voxen.Server.Audits/Voxen.Server.Audits.csproj b/src/modules/Voxen.Server.Audits/Voxen.Server.Audits.csproj new file mode 100644 index 0000000..c20b020 --- /dev/null +++ b/src/modules/Voxen.Server.Audits/Voxen.Server.Audits.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/modules/Voxen.Server.Domain/Entities/Audit.cs b/src/modules/Voxen.Server.Domain/Entities/Audit.cs new file mode 100644 index 0000000..ccda6fa --- /dev/null +++ b/src/modules/Voxen.Server.Domain/Entities/Audit.cs @@ -0,0 +1,49 @@ +using Voxen.Server.Domain.Enums; + +namespace Voxen.Server.Domain.Entities; + +/// +/// Represents an audit log entry within the Voxen server. +/// +public class Audit +{ + /// + /// Unique identifier for the audit log entry. + /// + public Guid Id { get; set; } + + /// + /// The user who performed the action. + /// + public Guid UserId { get; set; } + + /// + /// Navigation property for the user. + /// + public User User { get; set; } = null!; + + /// + /// The action that was performed (Create, Update, Delete, etc.). + /// + public AuditAction Action { get; set; } + + /// + /// The category/type of entity affected (Channel, User, Server, etc.). + /// + public AuditCategory Category { get; set; } + + /// + /// The identifier of the affected entity (e.g., ChannelId, UserId). + /// + public Guid? EntityId { get; set; } + + /// + /// Backing field for JSON storage in DB. + /// + public string? ChangesJson { get; set; } + + /// + /// Timestamp when the action occurred (UTC). + /// + public DateTime CreatedAt { get; set; } +} diff --git a/src/modules/Voxen.Server.Domain/Enums/AuditAction.cs b/src/modules/Voxen.Server.Domain/Enums/AuditAction.cs new file mode 100644 index 0000000..c3660ce --- /dev/null +++ b/src/modules/Voxen.Server.Domain/Enums/AuditAction.cs @@ -0,0 +1,12 @@ +namespace Voxen.Server.Domain.Enums; + +/// +/// Represents the type of action performed in an audit log entry. +/// +public enum AuditAction +{ + /// + /// Represents the creation of a new entity or record in the audit log. + /// + Create +} diff --git a/src/modules/Voxen.Server.Domain/Enums/AuditCategory.cs b/src/modules/Voxen.Server.Domain/Enums/AuditCategory.cs new file mode 100644 index 0000000..80ace03 --- /dev/null +++ b/src/modules/Voxen.Server.Domain/Enums/AuditCategory.cs @@ -0,0 +1,17 @@ +namespace Voxen.Server.Domain.Enums; + +/// +/// Represents the category of an action logged in the audit system. +/// +public enum AuditCategory +{ + /// + /// Represents a change to server-related properties logged in the audit system. + /// + Server, + + /// + /// Represents a change to user-related properties logged in the audit system. + /// + User +} diff --git a/src/modules/Voxen.Server.Domain/Migrations/20260316123356_InitialCreate.Designer.cs b/src/modules/Voxen.Server.Domain/Migrations/20260320110513_InitialCreate.Designer.cs similarity index 89% rename from src/modules/Voxen.Server.Domain/Migrations/20260316123356_InitialCreate.Designer.cs rename to src/modules/Voxen.Server.Domain/Migrations/20260320110513_InitialCreate.Designer.cs index 0fe7393..8e26b39 100644 --- a/src/modules/Voxen.Server.Domain/Migrations/20260316123356_InitialCreate.Designer.cs +++ b/src/modules/Voxen.Server.Domain/Migrations/20260320110513_InitialCreate.Designer.cs @@ -11,7 +11,7 @@ namespace Voxen.Server.Domain.Migrations { [DbContext(typeof(VoxenDbContext))] - [Migration("20260316123356_InitialCreate")] + [Migration("20260320110513_InitialCreate")] partial class InitialCreate { /// @@ -146,6 +146,39 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserTokens", (string)null); }); + modelBuilder.Entity("Voxen.Server.Domain.Entities.Audit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ChangesJson") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AuditLogs"); + }); + modelBuilder.Entity("Voxen.Server.Domain.Entities.Channel", b => { b.Property("Id") @@ -340,6 +373,17 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("Voxen.Server.Domain.Entities.Audit", b => + { + b.HasOne("Voxen.Server.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Voxen.Server.Domain.Entities.Message", b => { b.HasOne("Voxen.Server.Domain.Entities.Channel", "Channel") diff --git a/src/modules/Voxen.Server.Domain/Migrations/20260316123356_InitialCreate.cs b/src/modules/Voxen.Server.Domain/Migrations/20260320110513_InitialCreate.cs similarity index 90% rename from src/modules/Voxen.Server.Domain/Migrations/20260316123356_InitialCreate.cs rename to src/modules/Voxen.Server.Domain/Migrations/20260320110513_InitialCreate.cs index bfab387..988370b 100644 --- a/src/modules/Voxen.Server.Domain/Migrations/20260316123356_InitialCreate.cs +++ b/src/modules/Voxen.Server.Domain/Migrations/20260320110513_InitialCreate.cs @@ -186,6 +186,29 @@ protected override void Up(MigrationBuilder migrationBuilder) onDelete: ReferentialAction.Cascade); }); + migrationBuilder.CreateTable( + name: "AuditLogs", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + Action = table.Column(type: "TEXT", nullable: false), + Category = table.Column(type: "TEXT", nullable: false), + EntityId = table.Column(type: "TEXT", nullable: true), + ChangesJson = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditLogs", x => x.Id); + table.ForeignKey( + name: "FK_AuditLogs_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + migrationBuilder.CreateTable( name: "Messages", columns: table => new @@ -250,6 +273,11 @@ protected override void Up(MigrationBuilder migrationBuilder) column: "NormalizedUserName", unique: true); + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_UserId", + table: "AuditLogs", + column: "UserId"); + migrationBuilder.CreateIndex( name: "IX_Messages_ChannelId", table: "Messages", @@ -279,6 +307,9 @@ protected override void Down(MigrationBuilder migrationBuilder) migrationBuilder.DropTable( name: "AspNetUserTokens"); + migrationBuilder.DropTable( + name: "AuditLogs"); + migrationBuilder.DropTable( name: "Messages"); diff --git a/src/modules/Voxen.Server.Domain/Migrations/VoxenDbContextModelSnapshot.cs b/src/modules/Voxen.Server.Domain/Migrations/VoxenDbContextModelSnapshot.cs index e8d6771..e9e92bc 100644 --- a/src/modules/Voxen.Server.Domain/Migrations/VoxenDbContextModelSnapshot.cs +++ b/src/modules/Voxen.Server.Domain/Migrations/VoxenDbContextModelSnapshot.cs @@ -143,6 +143,39 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserTokens", (string)null); }); + modelBuilder.Entity("Voxen.Server.Domain.Entities.Audit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ChangesJson") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AuditLogs"); + }); + modelBuilder.Entity("Voxen.Server.Domain.Entities.Channel", b => { b.Property("Id") @@ -337,6 +370,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("Voxen.Server.Domain.Entities.Audit", b => + { + b.HasOne("Voxen.Server.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Voxen.Server.Domain.Entities.Message", b => { b.HasOne("Voxen.Server.Domain.Entities.Channel", "Channel") diff --git a/src/modules/Voxen.Server.Domain/Services/SeedData.cs b/src/modules/Voxen.Server.Domain/Services/SeedData.cs index 4d0a393..e25ec60 100644 --- a/src/modules/Voxen.Server.Domain/Services/SeedData.cs +++ b/src/modules/Voxen.Server.Domain/Services/SeedData.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using System.Text.Json; using Voxen.Server.Domain.Entities; using Voxen.Server.Domain.Enums; @@ -13,16 +14,24 @@ public static class SeedData /// /// Initializes the database with default data asynchronously. /// - /// The database context used to interact with the database. + /// The database context used to interact with the database. /// The user manager used to create and manage user accounts. /// A task that represents the asynchronous operation. - public static async Task InitializeDatabaseAsync(VoxenDbContext context, UserManager userManager) + public static async Task InitializeDatabaseAsync(VoxenDbContext db, UserManager userManager) { - await context.Database.MigrateAsync(); - if (await context.Server.AnyAsync()) + await db.Database.MigrateAsync(); + if (await db.Server.AnyAsync()) return; + + var adminId = await userManager.CreateAdmin(db); + db.CreateServer(adminId); - var defaultServer = new Entities.Server + await db.SaveChangesAsync(); + } + + private static void CreateServer(this VoxenDbContext db, Guid adminId) + { + var server = new Entities.Server { Id = Guid.NewGuid(), Name = "Voxen Server", @@ -31,6 +40,28 @@ public static async Task InitializeDatabaseAsync(VoxenDbContext context, UserMan LogoContentType = null }; + db.Server.Add(server); + db.AuditLogs.Add(new Audit + { + UserId = adminId, + Action = AuditAction.Create, + Category = AuditCategory.Server, + EntityId = server.Id, + ChangesJson = """ + [ + { + "PropertyName": "Name", + "OldValue": null, + "NewValue": "Voxen Server" + } + ] + """, + CreatedAt = DateTime.UtcNow + }); + } + + private static async Task CreateAdmin(this UserManager userManager, VoxenDbContext db) + { var adminUser = new User { Id = Guid.NewGuid(), @@ -38,7 +69,6 @@ public static async Task InitializeDatabaseAsync(VoxenDbContext context, UserMan Role = ServerRole.Admin }; - context.Server.Add(defaultServer); var result = await userManager.CreateAsync(adminUser, "Password123!"); if (!result.Succeeded) @@ -47,6 +77,29 @@ public static async Task InitializeDatabaseAsync(VoxenDbContext context, UserMan $"Failed to create admin user: {string.Join(", ", result.Errors.Select(e => e.Description))}"); } - await context.SaveChangesAsync(); + db.AuditLogs.Add(new Audit + { + UserId = adminUser.Id, + Action = AuditAction.Create, + Category = AuditCategory.User, + EntityId = adminUser.Id, + ChangesJson = """ + [ + { + "PropertyName": "Name", + "OldValue": null, + "NewValue": "admin" + }, + { + "PropertyName": "Role", + "OldValue": null, + "NewValue": "Admin" + } + ] + """, + CreatedAt = DateTime.UtcNow + }); + + return adminUser.Id; } } diff --git a/src/modules/Voxen.Server.Domain/VoxenDbContext.cs b/src/modules/Voxen.Server.Domain/VoxenDbContext.cs index e795148..b8d505b 100644 --- a/src/modules/Voxen.Server.Domain/VoxenDbContext.cs +++ b/src/modules/Voxen.Server.Domain/VoxenDbContext.cs @@ -32,6 +32,11 @@ public VoxenDbContext(DbContextOptions options) : base(options) /// public DbSet Messages => Set(); + /// + /// Gets the database set for audit logs. + /// + public DbSet AuditLogs => Set(); + /// protected override void OnModelCreating(ModelBuilder builder) { @@ -48,5 +53,24 @@ protected override void OnModelCreating(ModelBuilder builder) .WithMany(u => u.Messages) .HasForeignKey(m => m.UserId) .OnDelete(DeleteBehavior.Cascade); + + builder.Entity(entity => + { + entity.HasKey(a => a.Id); + + entity.Property(a => a.Action) + .HasConversion(); + + entity.Property(a => a.Category) + .HasConversion(); + + entity.Property(a => a.ChangesJson) + .HasColumnType("TEXT"); + + entity.HasOne(a => a.User) + .WithMany() + .HasForeignKey(a => a.UserId) + .OnDelete(DeleteBehavior.Restrict); + }); } }