fix(transport): NavMenu Admin-only visibility + BundleImportUnlockFailed audit + docker appsettings

- NavMenu: move Import Bundle out of the nested RequireDesign/RequireAdmin
  double-gate into the top-level Admin section so an Admin-only user sees it
  without needing the Design role; Export Bundle stays in the Design section.
- TransportImport: inject IAuditService + ScadaLinkDbContext; emit a
  BundleImportUnlockFailed audit row (best-effort, swallowed on failure) on
  every wrong-passphrase attempt in SubmitPassphraseAsync, with attempt
  number and error reason in afterState.
- docker central-node-a/b appsettings: add ScadaLink:Transport section with
  SourceEnvironment = "docker-cluster" so the importer picks up a non-null
  environment name in the audit trail.
- CentralUI.Tests: register IAuditService mock + SQLite in-memory
  ScadaLinkDbContext in TransportImportPageTests to satisfy the two new injects.
This commit is contained in:
Joseph Doherty
2026-05-24 05:59:04 -04:00
parent 9f1bb81993
commit a2b8b69281
5 changed files with 60 additions and 8 deletions

View File

@@ -58,6 +58,9 @@
"DispatchInterval": "00:00:05",
"DispatchBatchSize": 1000
},
"Transport": {
"SourceEnvironment": "docker-cluster"
},
"Logging": {
"MinimumLevel": "Information"
}

View File

@@ -58,6 +58,9 @@
"DispatchInterval": "00:00:05",
"DispatchBatchSize": 1000
},
"Transport": {
"SourceEnvironment": "docker-cluster"
},
"Logging": {
"MinimumLevel": "Information"
}

View File

@@ -32,6 +32,11 @@
<li class="nav-item">
<NavLink class="nav-link" href="/admin/api-keys">API Keys</NavLink>
</li>
@* Import Bundle requires Admin only — Design role is not sufficient.
Export Bundle lives in the Design section (RequireDesign). *@
<li class="nav-item">
<NavLink class="nav-link" href="/design/transport/import">Import Bundle</NavLink>
</li>
</NavSection>
</Authorized>
</AuthorizeView>
@@ -57,13 +62,6 @@
<li class="nav-item">
<NavLink class="nav-link" href="/design/transport/export">Export Bundle</NavLink>
</li>
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
<Authorized Context="importContext">
<li class="nav-item">
<NavLink class="nav-link" href="/design/transport/import">Import Bundle</NavLink>
</li>
</Authorized>
</AuthorizeView>
</NavSection>
</Authorized>
</AuthorizeView>

View File

@@ -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<TransportOptions> 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 ?? "<no-session>";
var entityName = _session?.Manifest.SourceEnvironment ?? "<unknown>";
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 =

View File

@@ -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<IBundleImporter>();
private readonly IAuditService _auditService = Substitute.For<IAuditService>();
public TransportImportPageTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
Services.AddSingleton(_importer);
Services.AddSingleton(_auditService);
Services.AddSingleton<IOptions<TransportOptions>>(
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<ScadaLinkDbContext>()
.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<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
Services.AddAuthorizationCore();