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:
@@ -58,6 +58,9 @@
|
|||||||
"DispatchInterval": "00:00:05",
|
"DispatchInterval": "00:00:05",
|
||||||
"DispatchBatchSize": 1000
|
"DispatchBatchSize": 1000
|
||||||
},
|
},
|
||||||
|
"Transport": {
|
||||||
|
"SourceEnvironment": "docker-cluster"
|
||||||
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"MinimumLevel": "Information"
|
"MinimumLevel": "Information"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,9 @@
|
|||||||
"DispatchInterval": "00:00:05",
|
"DispatchInterval": "00:00:05",
|
||||||
"DispatchBatchSize": 1000
|
"DispatchBatchSize": 1000
|
||||||
},
|
},
|
||||||
|
"Transport": {
|
||||||
|
"SourceEnvironment": "docker-cluster"
|
||||||
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"MinimumLevel": "Information"
|
"MinimumLevel": "Information"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,11 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="/admin/api-keys">API Keys</NavLink>
|
<NavLink class="nav-link" href="/admin/api-keys">API Keys</NavLink>
|
||||||
</li>
|
</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>
|
</NavSection>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
@@ -57,13 +62,6 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="/design/transport/export">Export Bundle</NavLink>
|
<NavLink class="nav-link" href="/design/transport/export">Export Bundle</NavLink>
|
||||||
</li>
|
</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>
|
</NavSection>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ using Microsoft.AspNetCore.Components.Authorization;
|
|||||||
using Microsoft.AspNetCore.Components.Forms;
|
using Microsoft.AspNetCore.Components.Forms;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ScadaLink.CentralUI.Auth;
|
using ScadaLink.CentralUI.Auth;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
using ScadaLink.Commons.Interfaces.Transport;
|
using ScadaLink.Commons.Interfaces.Transport;
|
||||||
using ScadaLink.Commons.Types.Transport;
|
using ScadaLink.Commons.Types.Transport;
|
||||||
|
using ScadaLink.ConfigurationDatabase;
|
||||||
using ScadaLink.Transport;
|
using ScadaLink.Transport;
|
||||||
using ScadaLink.Transport.Import;
|
using ScadaLink.Transport.Import;
|
||||||
|
|
||||||
@@ -57,6 +59,8 @@ public partial class TransportImport : ComponentBase
|
|||||||
[Inject] private NavigationManager Nav { get; set; } = default!;
|
[Inject] private NavigationManager Nav { get; set; } = default!;
|
||||||
[Inject] private AuthenticationStateProvider Auth { get; set; } = default!;
|
[Inject] private AuthenticationStateProvider Auth { get; set; } = default!;
|
||||||
[Inject] private IOptions<TransportOptions> Options { 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 ----
|
// ---- Wizard state ----
|
||||||
private ImportWizardStep _step = ImportWizardStep.Upload;
|
private ImportWizardStep _step = ImportWizardStep.Upload;
|
||||||
@@ -255,10 +259,37 @@ public partial class TransportImport : ComponentBase
|
|||||||
await LoadPreviewAndAdvanceAsync();
|
await LoadPreviewAndAdvanceAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (CryptographicException)
|
catch (CryptographicException ex)
|
||||||
{
|
{
|
||||||
_failedUnlockAttempts++;
|
_failedUnlockAttempts++;
|
||||||
_passphrase = string.Empty;
|
_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)
|
if (_failedUnlockAttempts >= Options.Value.MaxUnlockAttemptsPerSession)
|
||||||
{
|
{
|
||||||
_errorMessage =
|
_errorMessage =
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ using System.Security.Cryptography;
|
|||||||
using Bunit;
|
using Bunit;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using NSubstitute.ExceptionExtensions;
|
using NSubstitute.ExceptionExtensions;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
using ScadaLink.Commons.Interfaces.Transport;
|
using ScadaLink.Commons.Interfaces.Transport;
|
||||||
using ScadaLink.Commons.Types.Transport;
|
using ScadaLink.Commons.Types.Transport;
|
||||||
|
using ScadaLink.ConfigurationDatabase;
|
||||||
using ScadaLink.Security;
|
using ScadaLink.Security;
|
||||||
using ScadaLink.Transport;
|
using ScadaLink.Transport;
|
||||||
using TransportImportPage = ScadaLink.CentralUI.Components.Pages.Design.TransportImport;
|
using TransportImportPage = ScadaLink.CentralUI.Components.Pages.Design.TransportImport;
|
||||||
@@ -32,12 +36,14 @@ namespace ScadaLink.CentralUI.Tests.Pages.Design;
|
|||||||
public class TransportImportPageTests : BunitContext
|
public class TransportImportPageTests : BunitContext
|
||||||
{
|
{
|
||||||
private readonly IBundleImporter _importer = Substitute.For<IBundleImporter>();
|
private readonly IBundleImporter _importer = Substitute.For<IBundleImporter>();
|
||||||
|
private readonly IAuditService _auditService = Substitute.For<IAuditService>();
|
||||||
|
|
||||||
public TransportImportPageTests()
|
public TransportImportPageTests()
|
||||||
{
|
{
|
||||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||||
|
|
||||||
Services.AddSingleton(_importer);
|
Services.AddSingleton(_importer);
|
||||||
|
Services.AddSingleton(_auditService);
|
||||||
Services.AddSingleton<IOptions<TransportOptions>>(
|
Services.AddSingleton<IOptions<TransportOptions>>(
|
||||||
Microsoft.Extensions.Options.Options.Create(new TransportOptions
|
Microsoft.Extensions.Options.Options.Create(new TransportOptions
|
||||||
{
|
{
|
||||||
@@ -45,6 +51,17 @@ public class TransportImportPageTests : BunitContext
|
|||||||
MaxUnlockAttemptsPerSession = 3,
|
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");
|
var principal = BuildPrincipal("alice", "Admin");
|
||||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
|
||||||
Services.AddAuthorizationCore();
|
Services.AddAuthorizationCore();
|
||||||
|
|||||||
Reference in New Issue
Block a user