diff --git a/src/Areas/Identity/Pages/Account/LogOut.cshtml b/src/Areas/Identity/Pages/Account/LogOut.cshtml index b3b66748..ed279e7c 100644 --- a/src/Areas/Identity/Pages/Account/LogOut.cshtml +++ b/src/Areas/Identity/Pages/Account/LogOut.cshtml @@ -1,27 +1,53 @@ @page @using NodeGuard.Data.Models @using Microsoft.AspNetCore.Identity +@using NodeGuard.Services +@using NodeGuard.Helpers +@using System.Security.Claims @attribute [IgnoreAntiforgeryToken] @inject SignInManager SignInManager +@inject IAuditService AuditService +@inject IHttpContextAccessor HttpContextAccessor @functions { public async Task OnPost() { if (SignInManager.IsSignedIn(User)) { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + var username = User.Identity?.Name; await SignInManager.SignOutAsync(); + await AuditService.LogAsync( + AuditActionType.Logout, + AuditEventType.Success, + AuditObjectType.User, + userId, + userId, + username, + HttpContextAccessor.HttpContext.GetClientIpAddress(), + new { Username = username }); } return Redirect("~/"); } - public async Task OnGet() - { - if (SignInManager.IsSignedIn(User)) - { - await SignInManager.SignOutAsync(); - } - - return Redirect("~/"); - } + public async Task OnGet() + { + if (SignInManager.IsSignedIn(User)) + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + var username = User.Identity?.Name; + await SignInManager.SignOutAsync(); + await AuditService.LogAsync( + AuditActionType.Logout, + AuditEventType.Success, + AuditObjectType.User, + userId, + userId, + username, + HttpContextAccessor.HttpContext.GetClientIpAddress(), + new { Username = username }); + } + return Redirect("~/"); + } } diff --git a/src/Areas/Identity/Pages/Account/Login.cshtml.cs b/src/Areas/Identity/Pages/Account/Login.cshtml.cs index 35ab49a2..0b2a1236 100644 --- a/src/Areas/Identity/Pages/Account/Login.cshtml.cs +++ b/src/Areas/Identity/Pages/Account/Login.cshtml.cs @@ -15,6 +15,8 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.Logging; +using NodeGuard.Services; +using NodeGuard.Helpers; namespace NodeGuard.Areas.Identity.Pages.Account { @@ -22,11 +24,15 @@ public class LoginModel : PageModel { private readonly SignInManager _signInManager; private readonly ILogger _logger; + private readonly IAuditService _auditService; + private readonly UserManager _userManager; - public LoginModel(SignInManager signInManager, ILogger logger) + public LoginModel(SignInManager signInManager, ILogger logger, IAuditService auditService, UserManager userManager) { _signInManager = signInManager; _logger = logger; + _auditService = auditService; + _userManager = userManager; } /// @@ -128,6 +134,16 @@ public async Task OnPostAsync(string returnUrl = null) if (result.Succeeded) { _logger.LogInformation("User logged in."); + var user = await _userManager.FindByNameAsync(Input.Username); + await _auditService.LogAsync( + AuditActionType.Login, + AuditEventType.Success, + AuditObjectType.User, + user?.Id, + user?.Id, + Input.Username, + HttpContext.GetClientIpAddress(), + new { Username = Input.Username }); return LocalRedirect(returnUrl); } if (result.RequiresTwoFactor) @@ -137,10 +153,28 @@ public async Task OnPostAsync(string returnUrl = null) if (result.IsLockedOut) { _logger.LogWarning("User account locked out."); + await _auditService.LogAsync( + AuditActionType.Login, + AuditEventType.Failure, + AuditObjectType.User, + null, + null, + Input.Username, + HttpContext.GetClientIpAddress(), + new { Username = Input.Username, Reason = "Account locked out" }); return RedirectToPage("./Lockout"); } else { + await _auditService.LogAsync( + AuditActionType.Login, + AuditEventType.Failure, + AuditObjectType.User, + null, + null, + Input.Username, + HttpContext.GetClientIpAddress(), + new { Username = Input.Username, Reason = "Invalid credentials" }); ModelState.AddModelError(string.Empty, "Invalid login attempt."); return Page(); } diff --git a/src/Areas/Identity/Pages/Account/LoginWith2fa.cshtml.cs b/src/Areas/Identity/Pages/Account/LoginWith2fa.cshtml.cs index 048cb491..a8838f55 100644 --- a/src/Areas/Identity/Pages/Account/LoginWith2fa.cshtml.cs +++ b/src/Areas/Identity/Pages/Account/LoginWith2fa.cshtml.cs @@ -7,6 +7,8 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Identity; +using NodeGuard.Services; +using NodeGuard.Helpers; namespace NodeGuard.Areas.Identity.Pages.Account { @@ -15,15 +17,18 @@ public class LoginWith2faModel : PageModel private readonly SignInManager _signInManager; private readonly UserManager _userManager; private readonly ILogger _logger; + private readonly IAuditService _auditService; public LoginWith2faModel( SignInManager signInManager, UserManager userManager, - ILogger logger) + ILogger logger, + IAuditService auditService) { _signInManager = signInManager; _userManager = userManager; _logger = logger; + _auditService = auditService; } /// @@ -109,16 +114,43 @@ public async Task OnPostAsync(bool rememberMe, string returnUrl = if (result.Succeeded) { _logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", user.Id); + await _auditService.LogAsync( + AuditActionType.TwoFactorLogin, + AuditEventType.Success, + AuditObjectType.User, + userId, + userId, + user.UserName, + HttpContext.GetClientIpAddress(), + new { Username = user.UserName }); return LocalRedirect(returnUrl); } else if (result.IsLockedOut) { _logger.LogWarning("User with ID '{UserId}' account locked out.", user.Id); + await _auditService.LogAsync( + AuditActionType.TwoFactorLogin, + AuditEventType.Failure, + AuditObjectType.User, + userId, + userId, + user.UserName, + HttpContext.GetClientIpAddress(), + new { Username = user.UserName, Reason = "Account locked out" }); return RedirectToPage("./Lockout"); } else { _logger.LogWarning("Invalid authenticator code entered for user with ID '{UserId}'.", user.Id); + await _auditService.LogAsync( + AuditActionType.TwoFactorLogin, + AuditEventType.Failure, + AuditObjectType.User, + userId, + userId, + user.UserName, + HttpContext.GetClientIpAddress(), + new { Username = user.UserName, Reason = "Invalid authenticator code" }); ModelState.AddModelError(string.Empty, "Invalid authenticator code."); return Page(); } diff --git a/src/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml.cs b/src/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml.cs index 6a7c8df9..1006e6fd 100644 --- a/src/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml.cs +++ b/src/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.Logging; +using NodeGuard.Services; namespace NodeGuard.Areas.Identity.Pages.Account.Manage { @@ -16,13 +17,16 @@ public class Disable2faModel : PageModel { private readonly UserManager _userManager; private readonly ILogger _logger; + private readonly IAuditService _auditService; public Disable2faModel( UserManager userManager, - ILogger logger) + ILogger logger, + IAuditService auditService) { _userManager = userManager; _logger = logger; + _auditService = auditService; } /// @@ -63,6 +67,14 @@ public async Task OnPostAsync() } _logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", _userManager.GetUserId(User)); + + await _auditService.LogAsync( + AuditActionType.TwoFactorDisabled, + AuditEventType.Success, + AuditObjectType.User, + user.Id, + new { Username = user.UserName }); + StatusMessage = "2fa has been disabled. You can reenable 2fa when you setup an authenticator app"; return RedirectToPage("./TwoFactorAuthentication"); } diff --git a/src/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml.cs b/src/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml.cs index dff5b05f..7de753b0 100644 --- a/src/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml.cs +++ b/src/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml.cs @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.Logging; +using NodeGuard.Services; namespace NodeGuard.Areas.Identity.Pages.Account.Manage { @@ -22,17 +23,20 @@ public class EnableAuthenticatorModel : PageModel private readonly UserManager _userManager; private readonly ILogger _logger; private readonly UrlEncoder _urlEncoder; + private readonly IAuditService _auditService; private const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6"; public EnableAuthenticatorModel( UserManager userManager, ILogger logger, - UrlEncoder urlEncoder) + UrlEncoder urlEncoder, + IAuditService auditService) { _userManager = userManager; _logger = logger; _urlEncoder = urlEncoder; + _auditService = auditService; } /// @@ -129,6 +133,13 @@ public async Task OnPostAsync() var userId = await _userManager.GetUserIdAsync(user); _logger.LogInformation("User with ID '{UserId}' has enabled 2FA with an authenticator app.", userId); + await _auditService.LogAsync( + AuditActionType.TwoFactorEnabled, + AuditEventType.Success, + AuditObjectType.User, + userId, + new { Username = user.UserName }); + StatusMessage = "Your authenticator app has been verified."; if (await _userManager.CountRecoveryCodesAsync(user) == 0) diff --git a/src/Data/ApplicationDbContext.cs b/src/Data/ApplicationDbContext.cs index f3171e4a..48366aef 100644 --- a/src/Data/ApplicationDbContext.cs +++ b/src/Data/ApplicationDbContext.cs @@ -121,5 +121,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public DbSet ApiTokens { get; set; } public DbSet SwapOuts { get; set; } + + public DbSet AuditLogs { get; set; } } } diff --git a/src/Data/Models/AuditLog.cs b/src/Data/Models/AuditLog.cs new file mode 100644 index 00000000..8e85acb2 --- /dev/null +++ b/src/Data/Models/AuditLog.cs @@ -0,0 +1,187 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace NodeGuard.Data.Models; + +/// +/// Represents an audit log entry for tracking user actions and system events +/// +public class AuditLog +{ + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; set; } + + /// + /// Timestamp when the audit event occurred + /// + public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow; + + /// + /// The type of action that was performed + /// + public AuditActionType ActionType { get; set; } + + /// + /// The result/outcome of the action + /// + public AuditEventType EventType { get; set; } + + /// + /// The ID of the user who performed the action (nullable for system actions) + /// + [MaxLength(450)] + public string? UserId { get; set; } + + /// + /// The username of the user who performed the action + /// + [MaxLength(256)] + public string? Username { get; set; } + + /// + /// The IP address from which the action was performed + /// + [MaxLength(45)] + public string? IpAddress { get; set; } + + /// + /// The type of object that was affected by the action + /// + public AuditObjectType ObjectAffected { get; set; } + + /// + /// The ID of the object that was affected (e.g., wallet ID, channel ID) + /// + [MaxLength(450)] + public string? ObjectId { get; set; } + + /// + /// Additional details about the action in JSON format + /// + public string? Details { get; set; } +} + +/// +/// Types of actions that can be audited +/// +public enum AuditActionType +{ + // CRUD Operations + Create, + Update, + Delete, + + // Approval/Rejection + Approve, + Reject, + Cancel, + + // Authentication + Login, + Logout, + TwoFactorLogin, + LoginWithRecoveryCode, + + // 2FA Management + TwoFactorEnabled, + TwoFactorDisabled, + TwoFactorReset, + GenerateRecoveryCodes, + + // Password Management + ChangePassword, + SetPassword, + ResetPassword, + + // User Management + LockUser, + UnlockUser, + UpdateRoles, + + // Wallet Operations + Transfer, + Import, + Finalise, + Rescan, + FreezeUTXO, + UnfreezeUTXO, + AddKey, + + // Channel Operations + Close, + ForceClose, + MarkAsClosed, + EnableLiquidityManagement, + DisableLiquidityManagement, + + // API Token Operations + Block, + Unblock, + + // Swap Operations + SwapOut, + SwapIn, + + // Node Operations + AddNode, + UpdateNode, + DeleteNode, + + // Internal Wallet + GenerateInternalWallet, + + // Withdrawal Operations + BumpFee, + + // Signing + Sign +} + +/// +/// Types of event outcomes +/// +public enum AuditEventType +{ + Success, + Failure, + Attempt +} + +/// +/// Types of objects that can be affected by audited actions +/// +public enum AuditObjectType +{ + User, + Wallet, + Channel, + ChannelOperationRequest, + WalletWithdrawalRequest, + Node, + APIToken, + SwapOut, + LiquidityRule, + Key, + UTXO, + InternalWallet, + Session +} diff --git a/src/Data/Repositories/AuditLogRepository.cs b/src/Data/Repositories/AuditLogRepository.cs new file mode 100644 index 00000000..103b9e19 --- /dev/null +++ b/src/Data/Repositories/AuditLogRepository.cs @@ -0,0 +1,110 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using Microsoft.EntityFrameworkCore; +using NodeGuard.Data.Models; +using NodeGuard.Data.Repositories.Interfaces; + +namespace NodeGuard.Data.Repositories; + +public class AuditLogRepository : IAuditLogRepository +{ + private readonly IDbContextFactory _dbContextFactory; + private readonly ILogger _logger; + + public AuditLogRepository(IDbContextFactory dbContextFactory, ILogger logger) + { + _dbContextFactory = dbContextFactory; + _logger = logger; + } + + public async Task<(bool, string?)> AddAsync(AuditLog auditLog) + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + + try + { + auditLog.Timestamp = DateTimeOffset.UtcNow; + await dbContext.AuditLogs.AddAsync(auditLog); + await dbContext.SaveChangesAsync(); + return (true, null); + } + catch (Exception e) + { + _logger.LogError(e, "Error adding audit log entry"); + return (false, e.Message); + } + } + + public async Task<(List, int)> GetPaginatedAsync( + int page, + int pageSize, + AuditActionType? actionType = null, + AuditEventType? eventType = null, + AuditObjectType? objectType = null, + string? userId = null, + DateTimeOffset? fromDate = null, + DateTimeOffset? toDate = null) + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + + var query = dbContext.AuditLogs.AsQueryable(); + + if (actionType.HasValue) + query = query.Where(a => a.ActionType == actionType.Value); + + if (eventType.HasValue) + query = query.Where(a => a.EventType == eventType.Value); + + if (objectType.HasValue) + query = query.Where(a => a.ObjectAffected == objectType.Value); + + if (!string.IsNullOrEmpty(userId)) + query = query.Where(a => a.UserId == userId); + + if (fromDate.HasValue) + query = query.Where(a => a.Timestamp >= fromDate.Value); + + if (toDate.HasValue) + query = query.Where(a => a.Timestamp <= toDate.Value); + + var totalCount = await query.CountAsync(); + + var results = await query + .OrderByDescending(a => a.Timestamp) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + return (results, totalCount); + } + + public async Task DeleteOlderThanAsync(DateTimeOffset cutoffDate) + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + + var count = await dbContext.AuditLogs + .Where(a => a.Timestamp < cutoffDate) + .ExecuteDeleteAsync(); + + _logger.LogInformation("Deleted {Count} audit log entries older than {CutoffDate}", count, cutoffDate); + + return count; + } +} diff --git a/src/Data/Repositories/Interfaces/IAuditLogRepository.cs b/src/Data/Repositories/Interfaces/IAuditLogRepository.cs new file mode 100644 index 00000000..a9f1dc70 --- /dev/null +++ b/src/Data/Repositories/Interfaces/IAuditLogRepository.cs @@ -0,0 +1,48 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using NodeGuard.Data.Models; + +namespace NodeGuard.Data.Repositories.Interfaces; + +public interface IAuditLogRepository +{ + /// + /// Add a new audit log entry + /// + Task<(bool, string?)> AddAsync(AuditLog auditLog); + + /// + /// Get paginated audit logs with optional filtering + /// + Task<(List, int)> GetPaginatedAsync( + int page, + int pageSize, + AuditActionType? actionType = null, + AuditEventType? eventType = null, + AuditObjectType? objectType = null, + string? userId = null, + DateTimeOffset? fromDate = null, + DateTimeOffset? toDate = null); + + /// + /// Delete audit logs older than the specified date + /// + Task DeleteOlderThanAsync(DateTimeOffset cutoffDate); +} diff --git a/src/Helpers/HttpContextExtensions.cs b/src/Helpers/HttpContextExtensions.cs new file mode 100644 index 00000000..6331d5de --- /dev/null +++ b/src/Helpers/HttpContextExtensions.cs @@ -0,0 +1,60 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +namespace NodeGuard.Helpers; + +/// +/// Extension methods for HttpContext +/// +public static class HttpContextExtensions +{ + /// + /// Gets the client IP address from the HTTP context, considering proxies and load balancers. + /// Checks X-Forwarded-For and X-Real-IP headers before falling back to RemoteIpAddress. + /// + /// The HTTP context + /// The client IP address or null if not available + public static string? GetClientIpAddress(this HttpContext? httpContext) + { + if (httpContext == null) return null; + + // Check for forwarded IP (when behind a proxy/load balancer) + var forwardedFor = httpContext.Request.Headers["X-Forwarded-For"].FirstOrDefault(); + + if (!string.IsNullOrEmpty(forwardedFor)) + { + // X-Forwarded-For can contain multiple IPs, take the first one (client IP) + var ip = forwardedFor.Split(',').FirstOrDefault()?.Trim(); + if (!string.IsNullOrEmpty(ip)) + { + return ip; + } + } + + // Check X-Real-IP header + var realIp = httpContext.Request.Headers["X-Real-IP"].FirstOrDefault(); + if (!string.IsNullOrEmpty(realIp)) + { + return realIp; + } + + // Fall back to remote IP address + return httpContext.Connection.RemoteIpAddress?.ToString(); + } +} diff --git a/src/Migrations/20260115151544_AuditLog.Designer.cs b/src/Migrations/20260115151544_AuditLog.Designer.cs new file mode 100644 index 00000000..1b53e0ec --- /dev/null +++ b/src/Migrations/20260115151544_AuditLog.Designer.cs @@ -0,0 +1,1524 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodeGuard.Data; +using NodeGuard.Helpers; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NodeGuard.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260115151544_AuditLog")] + partial class AuditLog + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ApplicationUserNode", b => + { + b.Property("NodesId") + .HasColumnType("integer"); + + b.Property("UsersId") + .HasColumnType("text"); + + b.HasKey("NodesId", "UsersId"); + + b.HasIndex("UsersId"); + + b.ToTable("ApplicationUserNode"); + }); + + modelBuilder.Entity("ChannelOperationRequestFMUTXO", b => + { + b.Property("ChannelOperationRequestsId") + .HasColumnType("integer"); + + b.Property("UtxosId") + .HasColumnType("integer"); + + b.HasKey("ChannelOperationRequestsId", "UtxosId"); + + b.HasIndex("UtxosId"); + + b.ToTable("ChannelOperationRequestFMUTXO"); + }); + + modelBuilder.Entity("FMUTXOWalletWithdrawalRequest", b => + { + b.Property("UTXOsId") + .HasColumnType("integer"); + + b.Property("WalletWithdrawalRequestsId") + .HasColumnType("integer"); + + b.HasKey("UTXOsId", "WalletWithdrawalRequestsId"); + + b.HasIndex("WalletWithdrawalRequestsId"); + + b.ToTable("FMUTXOWalletWithdrawalRequest"); + }); + + modelBuilder.Entity("KeyWallet", b => + { + b.Property("KeysId") + .HasColumnType("integer"); + + b.Property("WalletsId") + .HasColumnType("integer"); + + b.HasKey("KeysId", "WalletsId"); + + b.HasIndex("WalletsId"); + + b.ToTable("KeyWallet"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("character varying(21)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + + b.HasDiscriminator().HasValue("IdentityUser"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.APIToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp without time zone"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.ToTable("ApiTokens"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionType") + .HasColumnType("integer"); + + b.Property("Details") + .HasColumnType("text"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("ObjectAffected") + .HasColumnType("integer"); + + b.Property("ObjectId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Username") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BtcCloseAddress") + .HasColumnType("text"); + + b.Property("ChanId") + .HasColumnType("numeric(20,0)"); + + b.Property("CreatedByNodeGuard") + .HasColumnType("boolean"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("DestinationNodeId") + .HasColumnType("integer"); + + b.Property("FundingTx") + .IsRequired() + .HasColumnType("text"); + + b.Property("FundingTxOutputIndex") + .HasColumnType("bigint"); + + b.Property("IsAutomatedLiquidityEnabled") + .HasColumnType("boolean"); + + b.Property("IsPrivate") + .HasColumnType("boolean"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("SourceNodeId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DestinationNodeId"); + + b.HasIndex("SourceNodeId"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountCryptoUnit") + .HasColumnType("integer"); + + b.Property("Changeless") + .HasColumnType("boolean"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("ClosingReason") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DestNodeId") + .HasColumnType("integer"); + + b.Property("FeeRate") + .HasColumnType("numeric"); + + b.Property("IsChannelPrivate") + .HasColumnType("boolean"); + + b.Property("MempoolRecommendedFeesType") + .HasColumnType("integer"); + + b.Property("RequestType") + .HasColumnType("integer"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("SourceNodeId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property>("StatusLogs") + .HasColumnType("jsonb"); + + b.Property("TxId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("WalletId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("DestNodeId"); + + b.HasIndex("SourceNodeId"); + + b.HasIndex("UserId"); + + b.HasIndex("WalletId"); + + b.ToTable("ChannelOperationRequests"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequestPSBT", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelOperationRequestId") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsFinalisedPSBT") + .HasColumnType("boolean"); + + b.Property("IsInternalWalletPSBT") + .HasColumnType("boolean"); + + b.Property("IsTemplatePSBT") + .HasColumnType("boolean"); + + b.Property("PSBT") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserSignerId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ChannelOperationRequestId"); + + b.HasIndex("UserSignerId"); + + b.ToTable("ChannelOperationRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.FMUTXO", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("OutputIndex") + .HasColumnType("bigint"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("TxId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("FMUTXOs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.InternalWallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("DerivationPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("MasterFingerprint") + .HasColumnType("text"); + + b.Property("MnemonicString") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("XPUB") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("InternalWallets"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Key", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("InternalWalletId") + .HasColumnType("integer"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsBIP39ImportedKey") + .HasColumnType("boolean"); + + b.Property("IsCompromised") + .HasColumnType("boolean"); + + b.Property("MasterFingerprint") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Path") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("XPUB") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InternalWalletId"); + + b.HasIndex("UserId"); + + b.ToTable("Keys"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.LiquidityRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsReverseSwapWalletRule") + .HasColumnType("boolean"); + + b.Property("MinimumLocalBalance") + .HasColumnType("numeric"); + + b.Property("MinimumRemoteBalance") + .HasColumnType("numeric"); + + b.Property("NodeId") + .HasColumnType("integer"); + + b.Property("RebalanceTarget") + .HasColumnType("numeric"); + + b.Property("ReverseSwapAddress") + .HasColumnType("text"); + + b.Property("ReverseSwapWalletId") + .HasColumnType("integer"); + + b.Property("SwapWalletId") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId") + .IsUnique(); + + b.HasIndex("NodeId"); + + b.HasIndex("ReverseSwapWalletId"); + + b.HasIndex("SwapWalletId"); + + b.ToTable("LiquidityRules"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AutoLiquidityManagementEnabled") + .HasColumnType("boolean"); + + b.Property("AutosweepEnabled") + .HasColumnType("boolean"); + + b.Property("ChannelAdminMacaroon") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Endpoint") + .HasColumnType("text"); + + b.Property("FortySwapEndpoint") + .HasColumnType("text"); + + b.Property("FortySwapWeight") + .HasColumnType("integer"); + + b.Property("FundsDestinationWalletId") + .HasColumnType("integer"); + + b.Property("IsNodeDisabled") + .HasColumnType("boolean"); + + b.Property("LoopSwapWeight") + .HasColumnType("integer"); + + b.Property("LoopdCert") + .HasColumnType("text"); + + b.Property("LoopdEndpoint") + .HasColumnType("text"); + + b.Property("LoopdMacaroon") + .HasColumnType("text"); + + b.Property("MaxSwapRoutingFeeRatio") + .HasColumnType("numeric"); + + b.Property("MaxSwapsInFlight") + .HasColumnType("integer"); + + b.Property("MinimumBalanceThresholdSats") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("SwapBudgetRefreshInterval") + .HasColumnType("interval"); + + b.Property("SwapBudgetSats") + .HasColumnType("bigint"); + + b.Property("SwapBudgetStartDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("SwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("SwapMinAmountSats") + .HasColumnType("bigint"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FundsDestinationWalletId"); + + b.HasIndex("PubKey") + .IsUnique(); + + b.ToTable("Nodes"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.SwapOut", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("DestinationWalletId") + .HasColumnType("integer"); + + b.Property("ErrorDetails") + .HasColumnType("text"); + + b.Property("IsManual") + .HasColumnType("boolean"); + + b.Property("LightningFeeSats") + .HasColumnType("bigint"); + + b.Property("NodeId") + .HasColumnType("integer"); + + b.Property("OnChainFeeSats") + .HasColumnType("bigint"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("ServiceFeeSats") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TxId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserRequestorId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DestinationWalletId"); + + b.HasIndex("NodeId"); + + b.HasIndex("UserRequestorId"); + + b.ToTable("SwapOuts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.UTXOTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Outpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key", "Outpoint") + .IsUnique(); + + b.ToTable("UTXOTags"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BIP39Seedphrase") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ImportedOutputDescriptor") + .HasColumnType("text"); + + b.Property("InternalWalletId") + .HasColumnType("integer"); + + b.Property("InternalWalletMasterFingerprint") + .HasColumnType("text"); + + b.Property("InternalWalletSubDerivationPath") + .HasColumnType("text"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsBIP39Imported") + .HasColumnType("boolean"); + + b.Property("IsCompromised") + .HasColumnType("boolean"); + + b.Property("IsFinalised") + .HasColumnType("boolean"); + + b.Property("IsHotWallet") + .HasColumnType("boolean"); + + b.Property("IsUnSortedMultiSig") + .HasColumnType("boolean"); + + b.Property("MofN") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletAddressType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("InternalWalletId"); + + b.HasIndex("InternalWalletSubDerivationPath", "InternalWalletMasterFingerprint") + .IsUnique(); + + b.ToTable("Wallets"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BumpingWalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.Property("Changeless") + .HasColumnType("boolean"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomFeeRate") + .HasColumnType("numeric"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("MempoolRecommendedFeesType") + .HasColumnType("integer"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("RejectCancelDescription") + .HasColumnType("text"); + + b.Property("RequestMetadata") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TxId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserRequestorId") + .HasColumnType("text"); + + b.Property("WalletId") + .HasColumnType("integer"); + + b.Property("WithdrawAllFunds") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("BumpingWalletWithdrawalRequestId"); + + b.HasIndex("UserRequestorId"); + + b.HasIndex("WalletId"); + + b.ToTable("WalletWithdrawalRequests"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestDestination", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("WalletWithdrawalRequestId"); + + b.ToTable("WalletWithdrawalRequestDestinations"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestPSBT", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsFinalisedPSBT") + .HasColumnType("boolean"); + + b.Property("IsInternalWalletPSBT") + .HasColumnType("boolean"); + + b.Property("IsTemplatePSBT") + .HasColumnType("boolean"); + + b.Property("PSBT") + .IsRequired() + .HasColumnType("text"); + + b.Property("SignerId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SignerId"); + + b.HasIndex("WalletWithdrawalRequestId"); + + b.ToTable("WalletWithdrawalRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ApplicationUser", b => + { + b.HasBaseType("Microsoft.AspNetCore.Identity.IdentityUser"); + + b.HasDiscriminator().HasValue("ApplicationUser"); + }); + + modelBuilder.Entity("ApplicationUserNode", b => + { + b.HasOne("NodeGuard.Data.Models.Node", null) + .WithMany() + .HasForeignKey("NodesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChannelOperationRequestFMUTXO", b => + { + b.HasOne("NodeGuard.Data.Models.ChannelOperationRequest", null) + .WithMany() + .HasForeignKey("ChannelOperationRequestsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.FMUTXO", null) + .WithMany() + .HasForeignKey("UtxosId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FMUTXOWalletWithdrawalRequest", b => + { + b.HasOne("NodeGuard.Data.Models.FMUTXO", null) + .WithMany() + .HasForeignKey("UTXOsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", null) + .WithMany() + .HasForeignKey("WalletWithdrawalRequestsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("KeyWallet", b => + { + b.HasOne("NodeGuard.Data.Models.Key", null) + .WithMany() + .HasForeignKey("KeysId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Wallet", null) + .WithMany() + .HasForeignKey("WalletsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.APIToken", b => + { + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => + { + b.HasOne("NodeGuard.Data.Models.Node", "DestinationNode") + .WithMany() + .HasForeignKey("DestinationNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Node", "SourceNode") + .WithMany() + .HasForeignKey("SourceNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DestinationNode"); + + b.Navigation("SourceNode"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithMany("ChannelOperationRequests") + .HasForeignKey("ChannelId"); + + b.HasOne("NodeGuard.Data.Models.Node", "DestNode") + .WithMany("ChannelOperationRequestsAsDestination") + .HasForeignKey("DestNodeId"); + + b.HasOne("NodeGuard.Data.Models.Node", "SourceNode") + .WithMany("ChannelOperationRequestsAsSource") + .HasForeignKey("SourceNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "User") + .WithMany("ChannelOperationRequests") + .HasForeignKey("UserId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "Wallet") + .WithMany("ChannelOperationRequestsAsSource") + .HasForeignKey("WalletId"); + + b.Navigation("Channel"); + + b.Navigation("DestNode"); + + b.Navigation("SourceNode"); + + b.Navigation("User"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequestPSBT", b => + { + b.HasOne("NodeGuard.Data.Models.ChannelOperationRequest", "ChannelOperationRequest") + .WithMany("ChannelOperationRequestPsbts") + .HasForeignKey("ChannelOperationRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserSigner") + .WithMany() + .HasForeignKey("UserSignerId"); + + b.Navigation("ChannelOperationRequest"); + + b.Navigation("UserSigner"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Key", b => + { + b.HasOne("NodeGuard.Data.Models.InternalWallet", "InternalWallet") + .WithMany() + .HasForeignKey("InternalWalletId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "User") + .WithMany("Keys") + .HasForeignKey("UserId"); + + b.Navigation("InternalWallet"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.LiquidityRule", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithMany("LiquidityRules") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Node", "Node") + .WithMany() + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Wallet", "ReverseSwapWallet") + .WithMany("LiquidityRulesAsReverseSwapWallet") + .HasForeignKey("ReverseSwapWalletId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "SwapWallet") + .WithMany("LiquidityRulesAsSwapWallet") + .HasForeignKey("SwapWalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + + b.Navigation("Node"); + + b.Navigation("ReverseSwapWallet"); + + b.Navigation("SwapWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.HasOne("NodeGuard.Data.Models.Wallet", "FundsDestinationWallet") + .WithMany() + .HasForeignKey("FundsDestinationWalletId"); + + b.Navigation("FundsDestinationWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.SwapOut", b => + { + b.HasOne("NodeGuard.Data.Models.Wallet", "DestinationWallet") + .WithMany("SwapOuts") + .HasForeignKey("DestinationWalletId"); + + b.HasOne("NodeGuard.Data.Models.Node", "Node") + .WithMany("SwapOuts") + .HasForeignKey("NodeId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserRequestor") + .WithMany() + .HasForeignKey("UserRequestorId"); + + b.Navigation("DestinationWallet"); + + b.Navigation("Node"); + + b.Navigation("UserRequestor"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.HasOne("NodeGuard.Data.Models.InternalWallet", "InternalWallet") + .WithMany() + .HasForeignKey("InternalWalletId"); + + b.Navigation("InternalWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "BumpingWalletWithdrawalRequest") + .WithMany() + .HasForeignKey("BumpingWalletWithdrawalRequestId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserRequestor") + .WithMany("WalletWithdrawalRequests") + .HasForeignKey("UserRequestorId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BumpingWalletWithdrawalRequest"); + + b.Navigation("UserRequestor"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestDestination", b => + { + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "WalletWithdrawalRequest") + .WithMany("WalletWithdrawalRequestDestinations") + .HasForeignKey("WalletWithdrawalRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WalletWithdrawalRequest"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestPSBT", b => + { + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "Signer") + .WithMany() + .HasForeignKey("SignerId"); + + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "WalletWithdrawalRequest") + .WithMany("WalletWithdrawalRequestPSBTs") + .HasForeignKey("WalletWithdrawalRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Signer"); + + b.Navigation("WalletWithdrawalRequest"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => + { + b.Navigation("ChannelOperationRequests"); + + b.Navigation("LiquidityRules"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => + { + b.Navigation("ChannelOperationRequestPsbts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.Navigation("ChannelOperationRequestsAsDestination"); + + b.Navigation("ChannelOperationRequestsAsSource"); + + b.Navigation("SwapOuts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.Navigation("ChannelOperationRequestsAsSource"); + + b.Navigation("LiquidityRulesAsReverseSwapWallet"); + + b.Navigation("LiquidityRulesAsSwapWallet"); + + b.Navigation("SwapOuts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.Navigation("WalletWithdrawalRequestDestinations"); + + b.Navigation("WalletWithdrawalRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ApplicationUser", b => + { + b.Navigation("ChannelOperationRequests"); + + b.Navigation("Keys"); + + b.Navigation("WalletWithdrawalRequests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Migrations/20260115151544_AuditLog.cs b/src/Migrations/20260115151544_AuditLog.cs new file mode 100644 index 00000000..a2cac265 --- /dev/null +++ b/src/Migrations/20260115151544_AuditLog.cs @@ -0,0 +1,44 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NodeGuard.Migrations +{ + /// + public partial class AuditLog : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AuditLogs", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Timestamp = table.Column(type: "timestamp with time zone", nullable: false), + ActionType = table.Column(type: "integer", nullable: false), + EventType = table.Column(type: "integer", nullable: false), + UserId = table.Column(type: "character varying(450)", maxLength: 450, nullable: true), + Username = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + IpAddress = table.Column(type: "character varying(45)", maxLength: 45, nullable: true), + ObjectAffected = table.Column(type: "integer", nullable: false), + ObjectId = table.Column(type: "character varying(450)", maxLength: 450, nullable: true), + Details = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditLogs", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AuditLogs"); + } + } +} diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index af708653..22278ade 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -19,7 +19,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("ProductVersion", "10.0.1") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -203,7 +203,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUsers", (string)null); - b.HasDiscriminator("Discriminator").HasValue("IdentityUser"); + b.HasDiscriminator().HasValue("IdentityUser"); b.UseTphMappingStrategy(); }); @@ -332,6 +332,50 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ApiTokens"); }); + modelBuilder.Entity("NodeGuard.Data.Models.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionType") + .HasColumnType("integer"); + + b.Property("Details") + .HasColumnType("text"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("ObjectAffected") + .HasColumnType("integer"); + + b.Property("ObjectId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Username") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.ToTable("AuditLogs"); + }); + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => { b.Property("Id") diff --git a/src/Program.cs b/src/Program.cs index 5873d7a9..cf4eaf77 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -95,6 +95,7 @@ public static async Task Main(string[] args) builder.Services.AddRazorPages(); builder.Services.AddServerSideBlazor().AddCircuitOptions(options => { options.DetailedErrors = true; }); + builder.Services.AddHttpContextAccessor(); //Dependency Injection builder.Services @@ -121,6 +122,7 @@ public static async Task Main(string[] args) builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddSingleton(); @@ -139,6 +141,7 @@ public static async Task Main(string[] args) builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddScoped(); //DbContext var dataSourceBuilder = new NpgsqlDataSourceBuilder(Constants.POSTGRES_CONNECTIONSTRING); @@ -329,6 +332,21 @@ public static async Task Main(string[] args) } }); }); + + // Audit Log Cleanup Job + q.AddJob(opts => + { + opts.DisallowConcurrentExecution(); + opts.WithIdentity(nameof(AuditLogCleanupJob)); + }); + + q.AddTrigger(opts => + { + opts.ForJob(nameof(AuditLogCleanupJob)) + .WithIdentity($"{nameof(AuditLogCleanupJob)}Trigger") + .StartNow() + .WithCronSchedule(Constants.AUDIT_LOG_CLEANUP_CRON); + }); }); // ASP.NET Core hosting diff --git a/src/Services/AuditService.cs b/src/Services/AuditService.cs new file mode 100644 index 00000000..4a083d77 --- /dev/null +++ b/src/Services/AuditService.cs @@ -0,0 +1,166 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using System.Security.Claims; +using System.Text.Json; +using NodeGuard.Data.Models; +using NodeGuard.Data.Repositories.Interfaces; +using NodeGuard.Helpers; + +namespace NodeGuard.Services; + +public class AuditService : IAuditService +{ + private readonly IAuditLogRepository _auditLogRepository; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ILogger _logger; + + public AuditService( + IAuditLogRepository auditLogRepository, + IHttpContextAccessor httpContextAccessor, + ILogger logger) + { + _auditLogRepository = auditLogRepository; + _httpContextAccessor = httpContextAccessor; + _logger = logger; + } + + public async Task LogAsync( + AuditActionType actionType, + AuditEventType eventType, + AuditObjectType objectAffected, + string? objectId = null, + string? userId = null, + string? username = null, + string? ipAddress = null, + object? details = null) + { + try + { + var detailsJson = details != null ? JsonSerializer.Serialize(details) : null; + + // Log to system logger + _logger.LogInformation( + "AUDIT: Action={ActionType} Event={EventType} Object={ObjectType} ObjectId={ObjectId} User={Username} UserId={UserId} IP={IpAddress} Details={Details}", + actionType, + eventType, + objectAffected, + objectId ?? "N/A", + username ?? "N/A", + userId ?? "N/A", + ipAddress ?? "N/A", + detailsJson ?? "N/A"); + + var auditLog = new AuditLog + { + ActionType = actionType, + EventType = eventType, + ObjectAffected = objectAffected, + ObjectId = objectId, + UserId = userId, + Username = username, + IpAddress = ipAddress, + Details = detailsJson + }; + + var (success, error) = await _auditLogRepository.AddAsync(auditLog); + + if (!success) + { + _logger.LogError("Failed to persist audit log to database: {Error}", error); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error logging audit event"); + } + } + + public async Task LogAsync( + AuditActionType actionType, + AuditEventType eventType, + AuditObjectType objectAffected, + string? objectId = null, + object? details = null) + { + var (userId, username, ipAddress) = ExtractContextInfo(); + + await LogAsync( + actionType, + eventType, + objectAffected, + objectId, + userId, + username, + ipAddress, + details); + } + + public async Task LogSystemAsync( + AuditActionType actionType, + AuditEventType eventType, + AuditObjectType objectAffected, + string? objectId = null, + object? details = null) + { + await LogAsync( + actionType, + eventType, + objectAffected, + objectId, + null, // No user ID for system operations + "SYSTEM", + null, // No IP address for system operations + details); + } + + private (string? UserId, string? Username, string? IpAddress) ExtractContextInfo() + { + string? userId = null; + string? username = null; + string? ipAddress = null; + + try + { + var httpContext = _httpContextAccessor.HttpContext; + + if (httpContext != null) + { + // Extract user info from claims + var user = httpContext.User; + if (user?.Identity?.IsAuthenticated == true) + { + userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + username = user.FindFirst(ClaimTypes.Name)?.Value + ?? user.FindFirst(ClaimTypes.Email)?.Value + ?? user.Identity.Name; + } + + // Extract IP address (considering proxies) + ipAddress = httpContext.GetClientIpAddress(); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error extracting context info for audit log"); + } + + return (userId, username, ipAddress); + } +} diff --git a/src/Services/IAuditService.cs b/src/Services/IAuditService.cs new file mode 100644 index 00000000..abd9879d --- /dev/null +++ b/src/Services/IAuditService.cs @@ -0,0 +1,58 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using NodeGuard.Data.Models; + +namespace NodeGuard.Services; + +public interface IAuditService +{ + /// + /// Log an audit event with all required fields + /// + Task LogAsync( + AuditActionType actionType, + AuditEventType eventType, + AuditObjectType objectAffected, + string? objectId = null, + string? userId = null, + string? username = null, + string? ipAddress = null, + object? details = null); + + /// + /// Log an audit event using HttpContext for user and IP extraction + /// + Task LogAsync( + AuditActionType actionType, + AuditEventType eventType, + AuditObjectType objectAffected, + string? objectId = null, + object? details = null); + + /// + /// Log an audit event from a system process (e.g., cron job) without HTTP context + /// + Task LogSystemAsync( + AuditActionType actionType, + AuditEventType eventType, + AuditObjectType objectAffected, + string? objectId = null, + object? details = null); +} diff --git a/test/NodeGuard.Tests/Helpers/HttpContextExtensionsTests.cs b/test/NodeGuard.Tests/Helpers/HttpContextExtensionsTests.cs new file mode 100644 index 00000000..e640d7d4 --- /dev/null +++ b/test/NodeGuard.Tests/Helpers/HttpContextExtensionsTests.cs @@ -0,0 +1,184 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using System.Net; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using NodeGuard.Helpers; + +namespace NodeGuard.Tests.Helpers; + +public class HttpContextExtensionsTests +{ + [Fact] + public void GetClientIpAddress_NullHttpContext_ReturnsNull() + { + // Arrange + HttpContext? httpContext = null; + + // Act + var result = httpContext.GetClientIpAddress(); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void GetClientIpAddress_XForwardedForHeader_ReturnsFirstIp() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Forwarded-For"] = "192.168.1.100"; + + // Act + var result = httpContext.GetClientIpAddress(); + + // Assert + result.Should().Be("192.168.1.100"); + } + + [Fact] + public void GetClientIpAddress_XForwardedForHeaderWithMultipleIps_ReturnsFirstIp() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Forwarded-For"] = "192.168.1.100, 10.0.0.1, 172.16.0.1"; + + // Act + var result = httpContext.GetClientIpAddress(); + + // Assert + result.Should().Be("192.168.1.100"); + } + + [Fact] + public void GetClientIpAddress_XForwardedForHeaderWithSpaces_ReturnsTrimmedIp() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Forwarded-For"] = " 192.168.1.100 , 10.0.0.1"; + + // Act + var result = httpContext.GetClientIpAddress(); + + // Assert + result.Should().Be("192.168.1.100"); + } + + [Fact] + public void GetClientIpAddress_XRealIpHeader_ReturnsIp() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Real-IP"] = "10.0.0.50"; + + // Act + var result = httpContext.GetClientIpAddress(); + + // Assert + result.Should().Be("10.0.0.50"); + } + + [Fact] + public void GetClientIpAddress_BothHeaders_PrefersXForwardedFor() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Forwarded-For"] = "192.168.1.100"; + httpContext.Request.Headers["X-Real-IP"] = "10.0.0.50"; + + // Act + var result = httpContext.GetClientIpAddress(); + + // Assert + result.Should().Be("192.168.1.100"); + } + + [Fact] + public void GetClientIpAddress_RemoteIpAddressFallback_ReturnsRemoteIp() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Connection.RemoteIpAddress = IPAddress.Parse("203.0.113.50"); + + // Act + var result = httpContext.GetClientIpAddress(); + + // Assert + result.Should().Be("203.0.113.50"); + } + + [Fact] + public void GetClientIpAddress_EmptyXForwardedFor_FallsBackToXRealIp() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Forwarded-For"] = ""; + httpContext.Request.Headers["X-Real-IP"] = "10.0.0.50"; + + // Act + var result = httpContext.GetClientIpAddress(); + + // Assert + result.Should().Be("10.0.0.50"); + } + + [Fact] + public void GetClientIpAddress_EmptyHeaders_FallsBackToRemoteIp() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Forwarded-For"] = ""; + httpContext.Request.Headers["X-Real-IP"] = ""; + httpContext.Connection.RemoteIpAddress = IPAddress.Parse("172.16.0.100"); + + // Act + var result = httpContext.GetClientIpAddress(); + + // Assert + result.Should().Be("172.16.0.100"); + } + + [Fact] + public void GetClientIpAddress_NoHeadersNoRemoteIp_ReturnsNull() + { + // Arrange + var httpContext = new DefaultHttpContext(); + + // Act + var result = httpContext.GetClientIpAddress(); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void GetClientIpAddress_IPv6Address_ReturnsIpv6() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Forwarded-For"] = "2001:0db8:85a3:0000:0000:8a2e:0370:7334"; + + // Act + var result = httpContext.GetClientIpAddress(); + + // Assert + result.Should().Be("2001:0db8:85a3:0000:0000:8a2e:0370:7334"); + } +}