diff --git a/src/Data/Models/AuditLog.cs b/src/Data/Models/AuditLog.cs index 8e85acb2..87981046 100644 --- a/src/Data/Models/AuditLog.cs +++ b/src/Data/Models/AuditLog.cs @@ -140,6 +140,12 @@ public enum AuditActionType // Swap Operations SwapOut, SwapIn, + SwapOutInitiated, + SwapOutCompleted, + + // Wallet Sweep Operations + WalletSweep, + NodeWalletAssigned, // Node Operations AddNode, diff --git a/src/Helpers/Constants.cs b/src/Helpers/Constants.cs index ac1a9962..1af5c803 100644 --- a/src/Helpers/Constants.cs +++ b/src/Helpers/Constants.cs @@ -75,6 +75,18 @@ public class Constants /// public static readonly int AUTO_LIQUIDITY_MANAGEMENT_INTERVAL_MINUTES = 10; + /// + /// The number of days to retain audit log entries before automatic cleanup. + /// Default is 180 days. Can be configured via AUDIT_LOG_RETENTION_DAYS environment variable. + /// + public static readonly int AUDIT_LOG_RETENTION_DAYS = 180; + + /// + /// Cron expression for the audit log cleanup job. Default runs daily at 3:00 AM. + /// Can be configured via AUDIT_LOG_CLEANUP_CRON environment variable. + /// + public static readonly string AUDIT_LOG_CLEANUP_CRON = "0 0 3 * * ?"; + // Observability public static readonly string? OTEL_EXPORTER_ENDPOINT; @@ -244,6 +256,12 @@ static Constants() var autoLiquidityManagementIntervalMinutes = Environment.GetEnvironmentVariable("AUTO_LIQUIDITY_MANAGEMENT_INTERVAL_MINUTES"); if (autoLiquidityManagementIntervalMinutes != null) AUTO_LIQUIDITY_MANAGEMENT_INTERVAL_MINUTES = int.Parse(autoLiquidityManagementIntervalMinutes); + // Audit Log + var auditLogRetentionDays = Environment.GetEnvironmentVariable("AUDIT_LOG_RETENTION_DAYS"); + if (auditLogRetentionDays != null) AUDIT_LOG_RETENTION_DAYS = int.Parse(auditLogRetentionDays); + + AUDIT_LOG_CLEANUP_CRON = Environment.GetEnvironmentVariable("AUDIT_LOG_CLEANUP_CRON") ?? AUDIT_LOG_CLEANUP_CRON; + // Observability //We need to expand the env-var with %ENV_VAR% for K8S var otelCollectorEndpointToBeExpanded = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT"); diff --git a/src/Jobs/AuditLogCleanupJob.cs b/src/Jobs/AuditLogCleanupJob.cs new file mode 100644 index 00000000..bf3efc6c --- /dev/null +++ b/src/Jobs/AuditLogCleanupJob.cs @@ -0,0 +1,62 @@ +/* + * 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.Repositories.Interfaces; +using Quartz; + +namespace NodeGuard.Jobs; + +/// +/// Job that cleans up old audit log entries based on the configured retention policy +/// +[DisallowConcurrentExecution] +public class AuditLogCleanupJob : IJob +{ + private readonly IAuditLogRepository _auditLogRepository; + private readonly ILogger _logger; + + public AuditLogCleanupJob(IAuditLogRepository auditLogRepository, ILogger logger) + { + _auditLogRepository = auditLogRepository; + _logger = logger; + } + + public async Task Execute(IJobExecutionContext context) + { + _logger.LogInformation("Starting audit log cleanup job"); + + try + { + var retentionDays = Constants.AUDIT_LOG_RETENTION_DAYS; + var cutoffDate = DateTimeOffset.UtcNow.AddDays(-retentionDays); + + _logger.LogInformation("Deleting audit logs older than {CutoffDate} (retention: {RetentionDays} days)", + cutoffDate, retentionDays); + + var deletedCount = await _auditLogRepository.DeleteOlderThanAsync(cutoffDate); + + _logger.LogInformation("Audit log cleanup completed. Deleted {DeletedCount} entries", deletedCount); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during audit log cleanup"); + throw; + } + } +} diff --git a/src/Jobs/AutoLiquidityManagementJob.cs b/src/Jobs/AutoLiquidityManagementJob.cs index 0420f895..f6862684 100644 --- a/src/Jobs/AutoLiquidityManagementJob.cs +++ b/src/Jobs/AutoLiquidityManagementJob.cs @@ -53,6 +53,7 @@ public class AutoLiquidityManagementJob : IJob private readonly ILightningService _lightningService; private readonly IWalletRepository _walletRepository; private readonly INBXplorerService _nbXplorerService; + private readonly IAuditService _auditService; public AutoLiquidityManagementJob( ILogger logger, @@ -62,7 +63,8 @@ public AutoLiquidityManagementJob( IFortySwapService fortySwapService, ILightningService lightningService, IWalletRepository walletRepository, - INBXplorerService nbXplorerService) + INBXplorerService nbXplorerService, + IAuditService auditService) { _logger = logger; _nodeRepository = nodeRepository; @@ -72,6 +74,7 @@ public AutoLiquidityManagementJob( _lightningService = lightningService; _walletRepository = walletRepository; _nbXplorerService = nbXplorerService; + _auditService = auditService; } public async Task Execute(IJobExecutionContext context) @@ -290,6 +293,25 @@ public async Task ManageNodeLiquidity(Node node, Canc _logger.LogInformation("Successfully initiated swap out {SwapId} for node {NodeName}", swapOut.ProviderId, node.Name); + + // Audit successful swap out initiation + await _auditService.LogSystemAsync( + AuditActionType.SwapOutInitiated, + AuditEventType.Success, + AuditObjectType.SwapOut, + swapOut.ProviderId, + new + { + NodeId = node.Id, + NodeName = node.Name, + Provider = selectedProvider.ToString(), + AmountSats = swapAmount, + DestinationAddress = destinationAddress, + DestinationWalletId = node.FundsDestinationWalletId, + ProviderId = swapResponse.Id, + IsAutomatic = true + }); + return ManageNodeLiquidityResult.Success; } catch (Exception ex) diff --git a/src/Jobs/MonitorSwapsJob.cs b/src/Jobs/MonitorSwapsJob.cs index a055902b..21ff1dca 100644 --- a/src/Jobs/MonitorSwapsJob.cs +++ b/src/Jobs/MonitorSwapsJob.cs @@ -13,14 +13,16 @@ public class MonitorSwapsJob : IJob private readonly INodeRepository _nodeRepository; private readonly ISwapOutRepository _swapOutRepository; private readonly ISwapsService _swapsService; + private readonly IAuditService _auditService; - public MonitorSwapsJob(ILogger logger, ISchedulerFactory schedulerFactory, INodeRepository nodeRepository, ISwapOutRepository swapOutRepository, ISwapsService swapsService) + public MonitorSwapsJob(ILogger logger, ISchedulerFactory schedulerFactory, INodeRepository nodeRepository, ISwapOutRepository swapOutRepository, ISwapsService swapsService, IAuditService auditService) { _logger = logger; _schedulerFactory = schedulerFactory; _nodeRepository = nodeRepository; _swapOutRepository = swapOutRepository; _swapsService = swapsService; + _auditService = auditService; } private void CleanUp(SwapOut swap, string errorMessage) @@ -67,6 +69,8 @@ public async Task Execute(IJobExecutionContext context) if (response.Status != swap.Status) { + var oldStatus = swap.Status; + if (response.Status == SwapOutStatus.Failed) { _logger.LogWarning("Swap {SwapId} status changed from {OldStatus} to {NewStatus}. Error: {ErrorMessage}", @@ -89,6 +93,31 @@ public async Task Execute(IJobExecutionContext context) _logger.LogError("Error updating swap {SwapId}: {Error}", swap.Id, error); continue; } + + // Audit swap completion (only for successful completions) + if (response.Status == SwapOutStatus.Completed) + { + await _auditService.LogSystemAsync( + AuditActionType.SwapOutCompleted, + AuditEventType.Success, + AuditObjectType.SwapOut, + swap.ProviderId, + new + { + SwapId = swap.Id, + NodeId = swap.NodeId, + Provider = swap.Provider.ToString(), + ProviderId = swap.ProviderId, + AmountSats = swap.SatsAmount, + TotalFeeSats = swap.TotalFees.Satoshi, + ServiceFeeSats = swap.ServiceFeeSats, + LightningFeeSats = swap.LightningFeeSats, + OnChainFeeSats = swap.OnChainFeeSats, + IsManual = swap.IsManual, + OldStatus = oldStatus.ToString(), + NewStatus = response.Status.ToString() + }); + } } } } diff --git a/src/Jobs/SweepNodeWalletsJob.cs b/src/Jobs/SweepNodeWalletsJob.cs index 52040b97..0fc6b3a9 100644 --- a/src/Jobs/SweepNodeWalletsJob.cs +++ b/src/Jobs/SweepNodeWalletsJob.cs @@ -37,12 +37,14 @@ public class SweepNodeWalletsJob : IJob private readonly INodeRepository _nodeRepository; private readonly IWalletRepository _walletRepository; private readonly INBXplorerService _nbXplorerService; + private readonly IAuditService _auditService; public SweepNodeWalletsJob(ILogger logger, INodeRepository nodeRepository, IWalletRepository walletRepository, INBXplorerService nbXplorerService, - ILightningClientService lightningClientService) + ILightningClientService lightningClientService, + IAuditService auditService) { _logger = logger; @@ -50,6 +52,7 @@ public SweepNodeWalletsJob(ILogger logger, _walletRepository = walletRepository; _nbXplorerService = nbXplorerService; _lightningClientService = lightningClientService; + _auditService = auditService; } public async Task Execute(IJobExecutionContext context) @@ -119,6 +122,23 @@ async Task SweepFunds(Node node, Wallet wallet, Lightning.LightningClient lightn sendManyResponse.Txid, returningAddress.Address); + // Audit successful wallet sweep + await _auditService.LogSystemAsync( + AuditActionType.WalletSweep, + AuditEventType.Success, + AuditObjectType.Wallet, + wallet.Id.ToString(), + new + { + NodeId = node.Id, + NodeName = node.Name, + WalletId = wallet.Id, + WalletName = wallet.Name, + AmountSats = sweepedFundsAmount, + TxId = sendManyResponse.Txid, + ReturnAddress = returningAddress.Address.ToString() + }); + //TODO We need to store the txid somewhere to monitor it.. } else @@ -193,6 +213,22 @@ async Task SweepFunds(Node node, Wallet wallet, Lightning.LightningClient lightn "Error while adding returning node wallet with id: {WalletId} to node: {NodeName}", wallet.Id, node.Name); } + else + { + // Audit successful wallet assignment + await _auditService.LogSystemAsync( + AuditActionType.NodeWalletAssigned, + AuditEventType.Success, + AuditObjectType.Node, + node.Id.ToString(), + new + { + NodeId = node.Id, + NodeName = node.Name, + WalletId = wallet.Id, + WalletName = wallet.Name + }); + } } else { diff --git a/src/Program.cs b/src/Program.cs index 612229d4..039b4c94 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -332,6 +332,23 @@ 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().WithSimpleSchedule(scheduleBuilder => + { + scheduleBuilder.WithIntervalInHours(24).RepeatForever(); + }); + }); }); // ASP.NET Core hosting diff --git a/test/NodeGuard.Tests/Jobs/AuditLogCleanupJobTests.cs b/test/NodeGuard.Tests/Jobs/AuditLogCleanupJobTests.cs new file mode 100644 index 00000000..d5e649fd --- /dev/null +++ b/test/NodeGuard.Tests/Jobs/AuditLogCleanupJobTests.cs @@ -0,0 +1,306 @@ +/* + * 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 FluentAssertions; +using Microsoft.Extensions.Logging; +using NodeGuard.Data.Repositories.Interfaces; +using Quartz; + +namespace NodeGuard.Jobs; + +public class AuditLogCleanupJobTests +{ + private Mock _auditLogRepositoryMock; + private Mock> _loggerMock; + private AuditLogCleanupJob _auditLogCleanupJob; + private Mock _jobExecutionContextMock; + + public AuditLogCleanupJobTests() + { + _auditLogRepositoryMock = new Mock(); + _loggerMock = new Mock>(); + _auditLogCleanupJob = new AuditLogCleanupJob( + _auditLogRepositoryMock.Object, + _loggerMock.Object + ); + _jobExecutionContextMock = new Mock(); + } + + [Fact] + public async Task Execute_SuccessfulCleanup_DeletesRecordsAndLogsSuccess() + { + // Arrange + var deletedCount = 150; + _auditLogRepositoryMock + .Setup(x => x.DeleteOlderThanAsync(It.IsAny())) + .ReturnsAsync(deletedCount); + + // Act + await _auditLogCleanupJob.Execute(_jobExecutionContextMock.Object); + + // Assert + _auditLogRepositoryMock.Verify( + x => x.DeleteOlderThanAsync(It.IsAny()), + Times.Once); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Starting audit log cleanup job")), + It.IsAny(), + It.IsAny>()), + Times.Once); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains($"Deleted {deletedCount} entries")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task Execute_NoRecordsToDelete_CompletesSuccessfully() + { + // Arrange + _auditLogRepositoryMock + .Setup(x => x.DeleteOlderThanAsync(It.IsAny())) + .ReturnsAsync(0); + + // Act + await _auditLogCleanupJob.Execute(_jobExecutionContextMock.Object); + + // Assert + _auditLogRepositoryMock.Verify( + x => x.DeleteOlderThanAsync(It.IsAny()), + Times.Once); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Deleted 0 entries")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task Execute_CalculatesCutoffDateCorrectly() + { + // Arrange + var beforeExecution = DateTimeOffset.UtcNow.AddDays(-Constants.AUDIT_LOG_RETENTION_DAYS); + DateTimeOffset capturedCutoffDate = DateTimeOffset.MinValue; + + _auditLogRepositoryMock + .Setup(x => x.DeleteOlderThanAsync(It.IsAny())) + .Callback(cutoff => capturedCutoffDate = cutoff) + .ReturnsAsync(10); + + // Act + await _auditLogCleanupJob.Execute(_jobExecutionContextMock.Object); + var afterExecution = DateTimeOffset.UtcNow.AddDays(-Constants.AUDIT_LOG_RETENTION_DAYS); + + // Assert + capturedCutoffDate.Should().BeOnOrAfter(beforeExecution); + capturedCutoffDate.Should().BeOnOrBefore(afterExecution); + capturedCutoffDate.Offset.Should().Be(TimeSpan.Zero, "cutoff date should use UTC"); + } + + [Fact] + public async Task Execute_UsesConfiguredRetentionDays() + { + // Arrange + var expectedRetentionDays = Constants.AUDIT_LOG_RETENTION_DAYS; + DateTimeOffset capturedCutoffDate = DateTimeOffset.MinValue; + + _auditLogRepositoryMock + .Setup(x => x.DeleteOlderThanAsync(It.IsAny())) + .Callback(cutoff => capturedCutoffDate = cutoff) + .ReturnsAsync(5); + + // Act + await _auditLogCleanupJob.Execute(_jobExecutionContextMock.Object); + + // Assert + var approximateExpectedCutoff = DateTimeOffset.UtcNow.AddDays(-expectedRetentionDays); + var difference = Math.Abs((capturedCutoffDate - approximateExpectedCutoff).TotalSeconds); + difference.Should().BeLessThan(2, "cutoff date should be calculated using the retention days constant"); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains($"retention: {expectedRetentionDays} days")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task Execute_RepositoryThrowsException_LogsErrorAndRethrows() + { + // Arrange + var expectedException = new InvalidOperationException("Database connection failed"); + _auditLogRepositoryMock + .Setup(x => x.DeleteOlderThanAsync(It.IsAny())) + .ThrowsAsync(expectedException); + + // Act + var act = async () => await _auditLogCleanupJob.Execute(_jobExecutionContextMock.Object); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("Database connection failed"); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Error during audit log cleanup")), + expectedException, + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task Execute_LogsStartMessage() + { + // Arrange + _auditLogRepositoryMock + .Setup(x => x.DeleteOlderThanAsync(It.IsAny())) + .ReturnsAsync(0); + + // Act + await _auditLogCleanupJob.Execute(_jobExecutionContextMock.Object); + + // Assert + _loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Starting audit log cleanup job")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task Execute_LogsCutoffDateAndRetentionDays() + { + // Arrange + _auditLogRepositoryMock + .Setup(x => x.DeleteOlderThanAsync(It.IsAny())) + .ReturnsAsync(25); + + // Act + await _auditLogCleanupJob.Execute(_jobExecutionContextMock.Object); + + // Assert + _loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => + v.ToString().Contains("Deleting audit logs older than") && + v.ToString().Contains($"retention: {Constants.AUDIT_LOG_RETENTION_DAYS} days")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task Execute_PassesCutoffDateToRepository() + { + // Arrange + DateTimeOffset capturedCutoffDate = DateTimeOffset.MinValue; + _auditLogRepositoryMock + .Setup(x => x.DeleteOlderThanAsync(It.IsAny())) + .Callback(cutoff => capturedCutoffDate = cutoff) + .ReturnsAsync(7); + + // Act + await _auditLogCleanupJob.Execute(_jobExecutionContextMock.Object); + + // Assert + capturedCutoffDate.Should().NotBe(DateTimeOffset.MinValue, "repository should be called with a valid cutoff date"); + capturedCutoffDate.Should().BeBefore(DateTimeOffset.UtcNow, "cutoff date should be in the past"); + + var expectedDaysAgo = DateTimeOffset.UtcNow.AddDays(-Constants.AUDIT_LOG_RETENTION_DAYS); + var difference = Math.Abs((capturedCutoffDate - expectedDaysAgo).TotalMinutes); + difference.Should().BeLessThan(1, "cutoff date should match the expected retention calculation"); + } + + [Fact] + public async Task Execute_MultipleExceptions_AllLogged() + { + // Arrange + var exception1 = new InvalidOperationException("First error"); + var exception2 = new Exception("Second error"); + + _auditLogRepositoryMock + .SetupSequence(x => x.DeleteOlderThanAsync(It.IsAny())) + .ThrowsAsync(exception1) + .ThrowsAsync(exception2); + + // Act & Assert - First execution + var act1 = async () => await _auditLogCleanupJob.Execute(_jobExecutionContextMock.Object); + await act1.Should().ThrowAsync(); + + // Act & Assert - Second execution + var act2 = async () => await _auditLogCleanupJob.Execute(_jobExecutionContextMock.Object); + await act2.Should().ThrowAsync(); + + // Assert both errors were logged + _loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Error during audit log cleanup")), + It.IsAny(), + It.IsAny>()), + Times.Exactly(2)); + } + + [Fact] + public async Task Execute_LargeDeleteCount_LogsCorrectly() + { + // Arrange + var largeDeleteCount = 1_000_000; + _auditLogRepositoryMock + .Setup(x => x.DeleteOlderThanAsync(It.IsAny())) + .ReturnsAsync(largeDeleteCount); + + // Act + await _auditLogCleanupJob.Execute(_jobExecutionContextMock.Object); + + // Assert + _loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains($"Deleted {largeDeleteCount} entries")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } +} diff --git a/test/NodeGuard.Tests/Jobs/AutoLiquidityManagementJobTests.cs b/test/NodeGuard.Tests/Jobs/AutoLiquidityManagementJobTests.cs index 42b29bcc..87b3fce0 100644 --- a/test/NodeGuard.Tests/Jobs/AutoLiquidityManagementJobTests.cs +++ b/test/NodeGuard.Tests/Jobs/AutoLiquidityManagementJobTests.cs @@ -41,6 +41,7 @@ public class AutoLiquidityManagementJobTests private Mock _lightningServiceMock; private Mock _walletRepositoryMock; private Mock _nbXplorerServiceMock; + private Mock _auditServiceMock; private AutoLiquidityManagementJob _autoLiquidityManagementJob; public AutoLiquidityManagementJobTests() @@ -53,6 +54,7 @@ public AutoLiquidityManagementJobTests() _lightningServiceMock = new Mock(); _walletRepositoryMock = new Mock(); _nbXplorerServiceMock = new Mock(); + _auditServiceMock = new Mock(); _autoLiquidityManagementJob = new AutoLiquidityManagementJob( _loggerMock.Object, @@ -62,7 +64,8 @@ public AutoLiquidityManagementJobTests() _fortySwapServiceMock.Object, _lightningServiceMock.Object, _walletRepositoryMock.Object, - _nbXplorerServiceMock.Object + _nbXplorerServiceMock.Object, + _auditServiceMock.Object ); }