diff --git a/docker/central-node-a/appsettings.Central.json b/docker/central-node-a/appsettings.Central.json index 8b3a492..89aa806 100644 --- a/docker/central-node-a/appsettings.Central.json +++ b/docker/central-node-a/appsettings.Central.json @@ -58,6 +58,9 @@ "DispatchInterval": "00:00:05", "DispatchBatchSize": 1000 }, + "Transport": { + "SourceEnvironment": "docker-cluster" + }, "Logging": { "MinimumLevel": "Information" } diff --git a/docker/central-node-b/appsettings.Central.json b/docker/central-node-b/appsettings.Central.json index 1192be5..ea4e734 100644 --- a/docker/central-node-b/appsettings.Central.json +++ b/docker/central-node-b/appsettings.Central.json @@ -58,6 +58,9 @@ "DispatchInterval": "00:00:05", "DispatchBatchSize": 1000 }, + "Transport": { + "SourceEnvironment": "docker-cluster" + }, "Logging": { "MinimumLevel": "Information" } diff --git a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor index 0196185..02f17df 100644 --- a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor +++ b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor @@ -32,6 +32,11 @@ + @* Import Bundle requires Admin only — Design role is not sufficient. + Export Bundle lives in the Design section (RequireDesign). *@ + @@ -57,13 +62,6 @@ - - - - - diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor.cs index 32fd68c..ace4412 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor.cs @@ -4,8 +4,10 @@ using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Forms; using Microsoft.Extensions.Options; using ScadaLink.CentralUI.Auth; +using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Interfaces.Transport; using ScadaLink.Commons.Types.Transport; +using ScadaLink.ConfigurationDatabase; using ScadaLink.Transport; using ScadaLink.Transport.Import; @@ -57,6 +59,8 @@ public partial class TransportImport : ComponentBase [Inject] private NavigationManager Nav { get; set; } = default!; [Inject] private AuthenticationStateProvider Auth { get; set; } = default!; [Inject] private IOptions Options { get; set; } = default!; + [Inject] private IAuditService AuditService { get; set; } = default!; + [Inject] private ScadaLinkDbContext DbContext { get; set; } = default!; // ---- Wizard state ---- private ImportWizardStep _step = ImportWizardStep.Upload; @@ -255,10 +259,37 @@ public partial class TransportImport : ComponentBase await LoadPreviewAndAdvanceAsync(); } } - catch (CryptographicException) + catch (CryptographicException ex) { _failedUnlockAttempts++; _passphrase = string.Empty; + + // Emit audit row for every wrong-passphrase attempt (BundleImportUnlockFailed). + // Best-effort — audit failure must never abort the user-facing action. + try + { + var user = await Auth.GetCurrentUsernameAsync(); + var entityId = _session?.Manifest.ContentHash ?? ""; + var entityName = _session?.Manifest.SourceEnvironment ?? ""; + await AuditService.LogAsync( + user: user, + action: "BundleImportUnlockFailed", + entityType: "Bundle", + entityId: entityId, + entityName: entityName, + afterState: new + { + AttemptNumber = _failedUnlockAttempts, + Reason = ex.Message, + }, + cancellationToken: CancellationToken.None); + await DbContext.SaveChangesAsync(); + } + catch + { + // Audit failure is non-fatal — swallow and continue. + } + if (_failedUnlockAttempts >= Options.Value.MaxUnlockAttemptsPerSession) { _errorMessage = diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs index 1e5cfa7..3797a22 100644 --- a/tests/ScadaLink.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs @@ -3,12 +3,16 @@ using System.Security.Cryptography; using Bunit; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NSubstitute; using NSubstitute.ExceptionExtensions; +using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Interfaces.Transport; using ScadaLink.Commons.Types.Transport; +using ScadaLink.ConfigurationDatabase; using ScadaLink.Security; using ScadaLink.Transport; using TransportImportPage = ScadaLink.CentralUI.Components.Pages.Design.TransportImport; @@ -32,12 +36,14 @@ namespace ScadaLink.CentralUI.Tests.Pages.Design; public class TransportImportPageTests : BunitContext { private readonly IBundleImporter _importer = Substitute.For(); + private readonly IAuditService _auditService = Substitute.For(); public TransportImportPageTests() { JSInterop.Mode = JSRuntimeMode.Loose; Services.AddSingleton(_importer); + Services.AddSingleton(_auditService); Services.AddSingleton>( Microsoft.Extensions.Options.Options.Create(new TransportOptions { @@ -45,6 +51,17 @@ public class TransportImportPageTests : BunitContext MaxUnlockAttemptsPerSession = 3, })); + // Provide a SQLite in-memory ScadaLinkDbContext so the page's + // DbContext.SaveChangesAsync() calls in the audit path succeed. + var dbOptions = new DbContextOptionsBuilder() + .UseSqlite("DataSource=:memory:") + .ConfigureWarnings(w => w.Ignore(RelationalEventId.AmbientTransactionWarning)) + .Options; + var dbContext = new ScadaLinkDbContext(dbOptions); + dbContext.Database.OpenConnection(); + dbContext.Database.EnsureCreated(); + Services.AddSingleton(dbContext); + var principal = BuildPrincipal("alice", "Admin"); Services.AddSingleton(new TestAuthStateProvider(principal)); Services.AddAuthorizationCore();