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 @@
API Keys
+ @* Import Bundle requires Admin only — Design role is not sufficient.
+ Export Bundle lives in the Design section (RequireDesign). *@
+
+ Import Bundle
+
@@ -57,13 +62,6 @@
Export Bundle
-
-
-
- Import Bundle
-
-
-
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();