Phase 2 PR 4 — close 4 open MXAccess findings (push frames + reconnect + write-await + read-cancel) #3
@@ -1,7 +1,12 @@
|
||||
@page "/clusters/{ClusterId}"
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@implements IAsyncDisposable
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@inject ClusterService ClusterSvc
|
||||
@inject GenerationService GenerationSvc
|
||||
@inject NavigationManager Nav
|
||||
@@ -12,6 +17,13 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_liveBanner is not null)
|
||||
{
|
||||
<div class="alert alert-info py-2 small">
|
||||
<strong>Live update:</strong> @_liveBanner
|
||||
<button type="button" class="btn-close float-end" @onclick="() => _liveBanner = null"></button>
|
||||
</div>
|
||||
}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="mb-0">@_cluster.Name</h1>
|
||||
@@ -97,10 +109,16 @@ else
|
||||
private ConfigGeneration? _currentPublished;
|
||||
private string _tab = "overview";
|
||||
private bool _busy;
|
||||
private HubConnection? _hub;
|
||||
private string? _liveBanner;
|
||||
|
||||
private string Tab(string key) => _tab == key ? "active" : string.Empty;
|
||||
|
||||
protected override async Task OnInitializedAsync() => await LoadAsync();
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
await ConnectHubAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
@@ -110,6 +128,25 @@ else
|
||||
_currentPublished = gens.FirstOrDefault(g => g.Status == GenerationStatus.Published);
|
||||
}
|
||||
|
||||
private async Task ConnectHubAsync()
|
||||
{
|
||||
_hub = new HubConnectionBuilder()
|
||||
.WithUrl(Nav.ToAbsoluteUri("/hubs/fleet"))
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hub.On<NodeStateChangedMessage>("NodeStateChanged", async msg =>
|
||||
{
|
||||
if (msg.ClusterId != ClusterId) return;
|
||||
_liveBanner = $"Node {msg.NodeId}: {msg.LastAppliedStatus ?? "seen"} at {msg.LastAppliedAt?.ToString("u") ?? msg.LastSeenAt?.ToString("u") ?? "-"}";
|
||||
await LoadAsync();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
});
|
||||
|
||||
await _hub.StartAsync();
|
||||
await _hub.SendAsync("SubscribeCluster", ClusterId);
|
||||
}
|
||||
|
||||
private async Task CreateDraftAsync()
|
||||
{
|
||||
_busy = true;
|
||||
@@ -120,4 +157,9 @@ else
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_hub is not null) await _hub.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
@using System.Security.Claims
|
||||
@using Microsoft.AspNetCore.Authentication
|
||||
@using Microsoft.AspNetCore.Authentication.Cookies
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Security
|
||||
@inject IHttpContextAccessor Http
|
||||
@inject ILdapAuthService LdapAuth
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-5">
|
||||
@@ -15,24 +16,24 @@
|
||||
<EditForm Model="_input" OnValidSubmit="SignInAsync" FormName="login">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<InputText @bind-Value="_input.Username" class="form-control"/>
|
||||
<InputText @bind-Value="_input.Username" class="form-control" autocomplete="username"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<InputText type="password" @bind-Value="_input.Password" class="form-control"/>
|
||||
<InputText type="password" @bind-Value="_input.Password" class="form-control" autocomplete="current-password"/>
|
||||
</div>
|
||||
|
||||
@if (_error is not null) { <div class="alert alert-danger">@_error</div> }
|
||||
|
||||
<button class="btn btn-primary w-100" type="submit">Sign in</button>
|
||||
<button class="btn btn-primary w-100" type="submit" disabled="@_busy">
|
||||
@(_busy ? "Signing in…" : "Sign in")
|
||||
</button>
|
||||
</EditForm>
|
||||
|
||||
<hr/>
|
||||
<small class="text-muted">
|
||||
<strong>Phase 1 note:</strong> real LDAP bind is deferred. This scaffold accepts
|
||||
any non-empty credentials and issues a <code>FleetAdmin</code> cookie. Replace the
|
||||
<code>LdapAuthService</code> stub with the ScadaLink-parity implementation before
|
||||
production deployment.
|
||||
LDAP bind against the configured directory. Dev defaults to GLAuth on
|
||||
<code>localhost:3893</code>.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,27 +49,52 @@
|
||||
|
||||
private Input _input = new();
|
||||
private string? _error;
|
||||
private bool _busy;
|
||||
|
||||
private async Task SignInAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_input.Username) || string.IsNullOrWhiteSpace(_input.Password))
|
||||
_error = null;
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
_error = "Username and password are required";
|
||||
return;
|
||||
if (string.IsNullOrWhiteSpace(_input.Username) || string.IsNullOrWhiteSpace(_input.Password))
|
||||
{
|
||||
_error = "Username and password are required";
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await LdapAuth.AuthenticateAsync(_input.Username, _input.Password, CancellationToken.None);
|
||||
if (!result.Success)
|
||||
{
|
||||
_error = result.Error ?? "Sign-in failed";
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Roles.Count == 0)
|
||||
{
|
||||
_error = "Sign-in succeeded but no Admin roles mapped for your LDAP groups. Contact your administrator.";
|
||||
return;
|
||||
}
|
||||
|
||||
var ctx = Http.HttpContext
|
||||
?? throw new InvalidOperationException("HttpContext unavailable at sign-in");
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.Name, result.DisplayName ?? result.Username ?? _input.Username),
|
||||
new(ClaimTypes.NameIdentifier, _input.Username),
|
||||
};
|
||||
foreach (var role in result.Roles)
|
||||
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||
foreach (var group in result.Groups)
|
||||
claims.Add(new Claim("ldap_group", group));
|
||||
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
new ClaimsPrincipal(identity));
|
||||
|
||||
ctx.Response.Redirect("/");
|
||||
}
|
||||
|
||||
var ctx = Http.HttpContext
|
||||
?? throw new InvalidOperationException("HttpContext unavailable for sign-in");
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.Name, _input.Username),
|
||||
new(ClaimTypes.Role, AdminRoles.FleetAdmin),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
new ClaimsPrincipal(identity));
|
||||
|
||||
ctx.Response.Redirect("/");
|
||||
finally { _busy = false; }
|
||||
}
|
||||
}
|
||||
|
||||
31
src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/AlertHub.cs
Normal file
31
src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/AlertHub.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Pushes sticky alerts (crash-loop circuit trips, failed applies, reservation-release
|
||||
/// anomalies) to subscribed admin clients. Alerts don't auto-clear — the operator acks them
|
||||
/// from the UI via <see cref="AcknowledgeAsync"/>.
|
||||
/// </summary>
|
||||
public sealed class AlertHub : Hub
|
||||
{
|
||||
public const string AllAlertsGroup = "__alerts__";
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, AllAlertsGroup);
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
/// <summary>Client-initiated ack. The server side of ack persistence is deferred — v2.1.</summary>
|
||||
public Task AcknowledgeAsync(string alertId) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
public sealed record AlertMessage(
|
||||
string AlertId,
|
||||
string Severity,
|
||||
string Title,
|
||||
string Detail,
|
||||
DateTime RaisedAtUtc,
|
||||
string? ClusterId,
|
||||
string? NodeId);
|
||||
39
src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/FleetStatusHub.cs
Normal file
39
src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/FleetStatusHub.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Pushes per-node generation-apply state changes (<c>ClusterNodeGenerationState</c>) to
|
||||
/// subscribed browser clients. Clients call <c>SubscribeCluster(clusterId)</c> on connect to
|
||||
/// scope notifications; the server sends <c>NodeStateChanged</c> messages whenever the poller
|
||||
/// observes a delta.
|
||||
/// </summary>
|
||||
public sealed class FleetStatusHub : Hub
|
||||
{
|
||||
public Task SubscribeCluster(string clusterId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clusterId)) return Task.CompletedTask;
|
||||
return Groups.AddToGroupAsync(Context.ConnectionId, GroupName(clusterId));
|
||||
}
|
||||
|
||||
public Task UnsubscribeCluster(string clusterId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clusterId)) return Task.CompletedTask;
|
||||
return Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupName(clusterId));
|
||||
}
|
||||
|
||||
/// <summary>Clients call this once to also receive fleet-wide status — used by the dashboard.</summary>
|
||||
public Task SubscribeFleet() => Groups.AddToGroupAsync(Context.ConnectionId, FleetGroup);
|
||||
|
||||
public const string FleetGroup = "__fleet__";
|
||||
public static string GroupName(string clusterId) => $"cluster:{clusterId}";
|
||||
}
|
||||
|
||||
public sealed record NodeStateChangedMessage(
|
||||
string NodeId,
|
||||
string ClusterId,
|
||||
long? CurrentGenerationId,
|
||||
string? LastAppliedStatus,
|
||||
string? LastAppliedError,
|
||||
DateTime? LastAppliedAt,
|
||||
DateTime? LastSeenAt);
|
||||
93
src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/FleetStatusPoller.cs
Normal file
93
src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/FleetStatusPoller.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Polls <c>ClusterNodeGenerationState</c> every <see cref="PollInterval"/> and publishes
|
||||
/// per-node deltas to <see cref="FleetStatusHub"/>. Also raises sticky
|
||||
/// <see cref="AlertMessage"/>s on transitions into <c>Failed</c>.
|
||||
/// </summary>
|
||||
public sealed class FleetStatusPoller(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IHubContext<FleetStatusHub> fleetHub,
|
||||
IHubContext<AlertHub> alertHub,
|
||||
ILogger<FleetStatusPoller> logger) : BackgroundService
|
||||
{
|
||||
public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly Dictionary<string, NodeStateSnapshot> _last = new();
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
logger.LogInformation("FleetStatusPoller starting — interval {Interval}s", PollInterval.TotalSeconds);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try { await PollOnceAsync(stoppingToken); }
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "FleetStatusPoller tick failed");
|
||||
}
|
||||
|
||||
try { await Task.Delay(PollInterval, stoppingToken); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task PollOnceAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
|
||||
var rows = await db.ClusterNodeGenerationStates.AsNoTracking()
|
||||
.Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new { s, n.ClusterId })
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var r in rows)
|
||||
{
|
||||
var snapshot = new NodeStateSnapshot(
|
||||
r.s.NodeId, r.ClusterId, r.s.CurrentGenerationId,
|
||||
r.s.LastAppliedStatus?.ToString(), r.s.LastAppliedError,
|
||||
r.s.LastAppliedAt, r.s.LastSeenAt);
|
||||
|
||||
var hadPrior = _last.TryGetValue(r.s.NodeId, out var prior);
|
||||
if (!hadPrior || prior != snapshot)
|
||||
{
|
||||
_last[r.s.NodeId] = snapshot;
|
||||
|
||||
var msg = new NodeStateChangedMessage(
|
||||
snapshot.NodeId, snapshot.ClusterId, snapshot.GenerationId,
|
||||
snapshot.Status, snapshot.Error, snapshot.AppliedAt, snapshot.SeenAt);
|
||||
|
||||
await fleetHub.Clients.Group(FleetStatusHub.GroupName(snapshot.ClusterId))
|
||||
.SendAsync("NodeStateChanged", msg, ct);
|
||||
await fleetHub.Clients.Group(FleetStatusHub.FleetGroup)
|
||||
.SendAsync("NodeStateChanged", msg, ct);
|
||||
|
||||
if (snapshot.Status == "Failed" && (!hadPrior || prior.Status != "Failed"))
|
||||
{
|
||||
var alert = new AlertMessage(
|
||||
AlertId: $"{snapshot.NodeId}:apply-failed",
|
||||
Severity: "error",
|
||||
Title: $"Apply failed on {snapshot.NodeId}",
|
||||
Detail: snapshot.Error ?? "(no detail)",
|
||||
RaisedAtUtc: DateTime.UtcNow,
|
||||
ClusterId: snapshot.ClusterId,
|
||||
NodeId: snapshot.NodeId);
|
||||
await alertHub.Clients.Group(AlertHub.AllAlertsGroup)
|
||||
.SendAsync("AlertRaised", alert, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Exposed for tests — forces a snapshot reset so stub data re-seeds.</summary>
|
||||
internal void ResetCache() => _last.Clear();
|
||||
|
||||
private readonly record struct NodeStateSnapshot(
|
||||
string NodeId, string ClusterId, long? GenerationId,
|
||||
string? Status, string? Error, DateTime? AppliedAt, DateTime? SeenAt);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Components;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
@@ -15,6 +17,7 @@ builder.Host.UseSerilog((ctx, cfg) => cfg
|
||||
|
||||
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(o =>
|
||||
@@ -45,6 +48,14 @@ builder.Services.AddScoped<ReservationService>();
|
||||
builder.Services.AddScoped<DraftValidationService>();
|
||||
builder.Services.AddScoped<AuditLogService>();
|
||||
|
||||
// LDAP auth — parity with ScadaLink's LdapAuthService (decision #102).
|
||||
builder.Services.Configure<LdapOptions>(
|
||||
builder.Configuration.GetSection("Authentication:Ldap"));
|
||||
builder.Services.AddScoped<ILdapAuthService, LdapAuthService>();
|
||||
|
||||
// SignalR real-time fleet status + alerts (admin-ui.md §"Real-Time Updates").
|
||||
builder.Services.AddHostedService<FleetStatusPoller>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSerilogRequestLogging();
|
||||
@@ -59,6 +70,9 @@ app.MapPost("/auth/logout", async (HttpContext ctx) =>
|
||||
ctx.Response.Redirect("/");
|
||||
});
|
||||
|
||||
app.MapHub<FleetStatusHub>("/hubs/fleet");
|
||||
app.MapHub<AlertHub>("/hubs/alerts");
|
||||
|
||||
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
6
src/ZB.MOM.WW.OtOpcUa.Admin/Security/ILdapAuthService.cs
Normal file
6
src/ZB.MOM.WW.OtOpcUa.Admin/Security/ILdapAuthService.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
public interface ILdapAuthService
|
||||
{
|
||||
Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default);
|
||||
}
|
||||
10
src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapAuthResult.cs
Normal file
10
src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapAuthResult.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
/// <summary>Outcome of an LDAP bind attempt. <see cref="Roles"/> is the mapped-set of Admin roles.</summary>
|
||||
public sealed record LdapAuthResult(
|
||||
bool Success,
|
||||
string? DisplayName,
|
||||
string? Username,
|
||||
IReadOnlyList<string> Groups,
|
||||
IReadOnlyList<string> Roles,
|
||||
string? Error);
|
||||
160
src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapAuthService.cs
Normal file
160
src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapAuthService.cs
Normal file
@@ -0,0 +1,160 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Novell.Directory.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
/// <summary>
|
||||
/// LDAP bind-and-search authentication mirrored from ScadaLink's <c>LdapAuthService</c>
|
||||
/// (CLAUDE.md memory: <c>scadalink_reference.md</c>) — same bind semantics, TLS guard, and
|
||||
/// service-account search-then-bind path. Adapted for the Admin app's role-mapping shape
|
||||
/// (LDAP group names → Admin roles via <see cref="LdapOptions.GroupToRole"/>).
|
||||
/// </summary>
|
||||
public sealed class LdapAuthService(IOptions<LdapOptions> options, ILogger<LdapAuthService> logger)
|
||||
: ILdapAuthService
|
||||
{
|
||||
private readonly LdapOptions _options = options.Value;
|
||||
|
||||
public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
return new(false, null, null, [], [], "Username is required");
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
return new(false, null, null, [], [], "Password is required");
|
||||
|
||||
if (!_options.UseTls && !_options.AllowInsecureLdap)
|
||||
return new(false, null, username, [], [],
|
||||
"Insecure LDAP is disabled. Enable UseTls or set AllowInsecureLdap for dev/test.");
|
||||
|
||||
try
|
||||
{
|
||||
using var conn = new LdapConnection();
|
||||
if (_options.UseTls) conn.SecureSocketLayer = true;
|
||||
|
||||
await Task.Run(() => conn.Connect(_options.Server, _options.Port), ct);
|
||||
|
||||
var bindDn = await ResolveUserDnAsync(conn, username, ct);
|
||||
await Task.Run(() => conn.Bind(bindDn, password), ct);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.ServiceAccountDn))
|
||||
await Task.Run(() => conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword), ct);
|
||||
|
||||
var displayName = username;
|
||||
var groups = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
var filter = $"(cn={EscapeLdapFilter(username)})";
|
||||
var results = await Task.Run(() =>
|
||||
conn.Search(_options.SearchBase, LdapConnection.ScopeSub, filter,
|
||||
attrs: null, // request ALL attributes so we can inspect memberOf + dn-derived group
|
||||
typesOnly: false), ct);
|
||||
|
||||
while (results.HasMore())
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = results.Next();
|
||||
var name = entry.GetAttribute(_options.DisplayNameAttribute);
|
||||
if (name is not null) displayName = name.StringValue;
|
||||
|
||||
var groupAttr = entry.GetAttribute(_options.GroupAttribute);
|
||||
if (groupAttr is not null)
|
||||
{
|
||||
foreach (var groupDn in groupAttr.StringValueArray)
|
||||
groups.Add(ExtractFirstRdnValue(groupDn));
|
||||
}
|
||||
|
||||
// Fallback: GLAuth places users under ou=PrimaryGroup,baseDN. When the
|
||||
// directory doesn't populate memberOf (or populates it differently), the
|
||||
// user's primary group name is recoverable from the second RDN of the DN.
|
||||
if (groups.Count == 0 && !string.IsNullOrEmpty(entry.Dn))
|
||||
{
|
||||
var primary = ExtractOuSegment(entry.Dn);
|
||||
if (primary is not null) groups.Add(primary);
|
||||
}
|
||||
}
|
||||
catch (LdapException) { break; } // no-more-entries signalled by exception
|
||||
}
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "LDAP attribute lookup failed for {User}", username);
|
||||
}
|
||||
|
||||
conn.Disconnect();
|
||||
|
||||
var roles = RoleMapper.Map(groups, _options.GroupToRole);
|
||||
return new(true, displayName, username, groups, roles, null);
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "LDAP bind failed for {User}", username);
|
||||
return new(false, null, username, [], [], "Invalid username or password");
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(ex, "Unexpected LDAP error for {User}", username);
|
||||
return new(false, null, username, [], [], "Unexpected authentication error");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> ResolveUserDnAsync(LdapConnection conn, string username, CancellationToken ct)
|
||||
{
|
||||
if (username.Contains('=')) return username; // already a DN
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.ServiceAccountDn))
|
||||
{
|
||||
await Task.Run(() =>
|
||||
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword), ct);
|
||||
|
||||
var filter = $"(uid={EscapeLdapFilter(username)})";
|
||||
var results = await Task.Run(() =>
|
||||
conn.Search(_options.SearchBase, LdapConnection.ScopeSub, filter, ["dn"], false), ct);
|
||||
|
||||
if (results.HasMore())
|
||||
return results.Next().Dn;
|
||||
|
||||
throw new LdapException("User not found", LdapException.NoSuchObject,
|
||||
$"No entry for uid={username}");
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(_options.SearchBase)
|
||||
? $"cn={username}"
|
||||
: $"cn={username},{_options.SearchBase}";
|
||||
}
|
||||
|
||||
internal static string EscapeLdapFilter(string input) =>
|
||||
input.Replace("\\", "\\5c")
|
||||
.Replace("*", "\\2a")
|
||||
.Replace("(", "\\28")
|
||||
.Replace(")", "\\29")
|
||||
.Replace("\0", "\\00");
|
||||
|
||||
/// <summary>
|
||||
/// Pulls the first <c>ou=Value</c> segment from a DN. GLAuth encodes a user's primary
|
||||
/// group as an <c>ou=</c> RDN immediately above the user's <c>cn=</c>, so this recovers
|
||||
/// the group name when <see cref="LdapOptions.GroupAttribute"/> is absent from the entry.
|
||||
/// </summary>
|
||||
internal static string? ExtractOuSegment(string dn)
|
||||
{
|
||||
var segments = dn.Split(',');
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var trimmed = segment.Trim();
|
||||
if (trimmed.StartsWith("ou=", StringComparison.OrdinalIgnoreCase))
|
||||
return trimmed[3..];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static string ExtractFirstRdnValue(string dn)
|
||||
{
|
||||
var equalsIdx = dn.IndexOf('=');
|
||||
if (equalsIdx < 0) return dn;
|
||||
|
||||
var valueStart = equalsIdx + 1;
|
||||
var commaIdx = dn.IndexOf(',', valueStart);
|
||||
return commaIdx > valueStart ? dn[valueStart..commaIdx] : dn[valueStart..];
|
||||
}
|
||||
}
|
||||
38
src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapOptions.cs
Normal file
38
src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapOptions.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
/// <summary>
|
||||
/// LDAP + role-mapping configuration for the Admin UI. Bound from <c>appsettings.json</c>
|
||||
/// <c>Authentication:Ldap</c> section. Defaults point at the local GLAuth dev instance (see
|
||||
/// <c>C:\publish\glauth\auth.md</c>).
|
||||
/// </summary>
|
||||
public sealed class LdapOptions
|
||||
{
|
||||
public const string SectionName = "Authentication:Ldap";
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string Server { get; set; } = "localhost";
|
||||
public int Port { get; set; } = 3893;
|
||||
public bool UseTls { get; set; }
|
||||
|
||||
/// <summary>Dev-only escape hatch — must be <c>false</c> in production.</summary>
|
||||
public bool AllowInsecureLdap { get; set; }
|
||||
|
||||
public string SearchBase { get; set; } = "dc=lmxopcua,dc=local";
|
||||
|
||||
/// <summary>
|
||||
/// Service-account DN used for search-then-bind. When empty, a direct-bind with
|
||||
/// <c>cn={user},{SearchBase}</c> is attempted.
|
||||
/// </summary>
|
||||
public string ServiceAccountDn { get; set; } = string.Empty;
|
||||
public string ServiceAccountPassword { get; set; } = string.Empty;
|
||||
|
||||
public string DisplayNameAttribute { get; set; } = "cn";
|
||||
public string GroupAttribute { get; set; } = "memberOf";
|
||||
|
||||
/// <summary>
|
||||
/// Maps LDAP group name → Admin role. Group match is case-insensitive. A user gets every
|
||||
/// role whose source group is in their membership list. Example dev mapping:
|
||||
/// <code>"ReadOnly":"ConfigViewer","ReadWrite":"ConfigEditor","AlarmAck":"FleetAdmin"</code>
|
||||
/// </summary>
|
||||
public Dictionary<string, string> GroupToRole { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
23
src/ZB.MOM.WW.OtOpcUa.Admin/Security/RoleMapper.cs
Normal file
23
src/ZB.MOM.WW.OtOpcUa.Admin/Security/RoleMapper.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic LDAP-group-to-Admin-role mapper driven by <see cref="LdapOptions.GroupToRole"/>.
|
||||
/// Every returned role corresponds to a group the user actually holds; no inference.
|
||||
/// </summary>
|
||||
public static class RoleMapper
|
||||
{
|
||||
public static IReadOnlyList<string> Map(
|
||||
IReadOnlyCollection<string> ldapGroups,
|
||||
IReadOnlyDictionary<string, string> groupToRole)
|
||||
{
|
||||
if (groupToRole.Count == 0) return [];
|
||||
|
||||
var roles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var group in ldapGroups)
|
||||
{
|
||||
if (groupToRole.TryGetValue(group, out var role))
|
||||
roles.Add(role);
|
||||
}
|
||||
return [.. roles];
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0"/>
|
||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0"/>
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -20,6 +22,10 @@
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Admin.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
|
||||
@@ -2,6 +2,25 @@
|
||||
"ConnectionStrings": {
|
||||
"ConfigDb": "Server=localhost,14330;Database=OtOpcUaConfig;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=True;Encrypt=False;"
|
||||
},
|
||||
"Authentication": {
|
||||
"Ldap": {
|
||||
"Enabled": true,
|
||||
"Server": "localhost",
|
||||
"Port": 3893,
|
||||
"UseTls": false,
|
||||
"AllowInsecureLdap": true,
|
||||
"SearchBase": "dc=lmxopcua,dc=local",
|
||||
"ServiceAccountDn": "cn=serviceaccount,ou=svcaccts,dc=lmxopcua,dc=local",
|
||||
"ServiceAccountPassword": "serviceaccount123",
|
||||
"DisplayNameAttribute": "cn",
|
||||
"GroupAttribute": "memberOf",
|
||||
"GroupToRole": {
|
||||
"ReadOnly": "ConfigViewer",
|
||||
"ReadWrite": "ConfigEditor",
|
||||
"AlarmAck": "FleetAdmin"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": "Information"
|
||||
}
|
||||
|
||||
155
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/FleetStatusPollerTests.cs
Normal file
155
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/FleetStatusPollerTests.cs
Normal file
@@ -0,0 +1,155 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class FleetStatusPollerTests : IDisposable
|
||||
{
|
||||
private const string DefaultServer = "localhost,14330";
|
||||
private const string DefaultSaPassword = "OtOpcUaDev_2026!";
|
||||
|
||||
private readonly string _databaseName = $"OtOpcUaPollerTest_{Guid.NewGuid():N}";
|
||||
private readonly string _connectionString;
|
||||
private readonly ServiceProvider _sp;
|
||||
|
||||
public FleetStatusPollerTests()
|
||||
{
|
||||
var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer;
|
||||
var password = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword;
|
||||
_connectionString =
|
||||
$"Server={server};Database={_databaseName};User Id=sa;Password={password};TrustServerCertificate=True;Encrypt=False;";
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSignalR();
|
||||
services.AddDbContext<OtOpcUaConfigDbContext>(o => o.UseSqlServer(_connectionString));
|
||||
_sp = services.BuildServiceProvider();
|
||||
|
||||
using var scope = _sp.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>().Database.Migrate();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_sp.Dispose();
|
||||
using var conn = new Microsoft.Data.SqlClient.SqlConnection(
|
||||
new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(_connectionString)
|
||||
{ InitialCatalog = "master" }.ConnectionString);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $@"
|
||||
IF DB_ID(N'{_databaseName}') IS NOT NULL
|
||||
BEGIN
|
||||
ALTER DATABASE [{_databaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
|
||||
DROP DATABASE [{_databaseName}];
|
||||
END";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Poller_detects_new_apply_state_and_pushes_to_fleet_hub()
|
||||
{
|
||||
// Seed a cluster + node + credential + generation + apply state.
|
||||
using (var scope = _sp.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
db.ServerClusters.Add(new ServerCluster
|
||||
{
|
||||
ClusterId = "p-1", Name = "Poll test", Enterprise = "zb", Site = "dev",
|
||||
NodeCount = 1, RedundancyMode = RedundancyMode.None, Enabled = true, CreatedBy = "t",
|
||||
});
|
||||
db.ClusterNodes.Add(new ClusterNode
|
||||
{
|
||||
NodeId = "p-1-a", ClusterId = "p-1", RedundancyRole = RedundancyRole.Primary,
|
||||
Host = "localhost", OpcUaPort = 4840, DashboardPort = 5001,
|
||||
ApplicationUri = "urn:p1:test", ServiceLevelBase = 200, Enabled = true, CreatedBy = "t",
|
||||
});
|
||||
var gen = new ConfigGeneration
|
||||
{
|
||||
ClusterId = "p-1", Status = GenerationStatus.Published, CreatedBy = "t",
|
||||
PublishedBy = "t", PublishedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.ConfigGenerations.Add(gen);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
db.ClusterNodeGenerationStates.Add(new ClusterNodeGenerationState
|
||||
{
|
||||
NodeId = "p-1-a", CurrentGenerationId = gen.GenerationId,
|
||||
LastAppliedStatus = NodeApplyStatus.Applied,
|
||||
LastAppliedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Recording hub contexts — capture what would be pushed to clients.
|
||||
var recorder = new RecordingHubClients();
|
||||
var fleetHub = new RecordingHubContext<FleetStatusHub>(recorder);
|
||||
var alertHub = new RecordingHubContext<AlertHub>(new RecordingHubClients());
|
||||
|
||||
var poller = new FleetStatusPoller(
|
||||
_sp.GetRequiredService<IServiceScopeFactory>(),
|
||||
fleetHub, alertHub, NullLogger<FleetStatusPoller>.Instance);
|
||||
|
||||
await poller.PollOnceAsync(CancellationToken.None);
|
||||
|
||||
var match = recorder.SentMessages.FirstOrDefault(m =>
|
||||
m.Method == "NodeStateChanged" &&
|
||||
m.Args.Length > 0 &&
|
||||
m.Args[0] is NodeStateChangedMessage msg &&
|
||||
msg.NodeId == "p-1-a");
|
||||
match.ShouldNotBeNull("poller should have pushed a NodeStateChanged for p-1-a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Poller_raises_alert_on_transition_into_Failed()
|
||||
{
|
||||
using (var scope = _sp.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
db.ServerClusters.Add(new ServerCluster
|
||||
{
|
||||
ClusterId = "p-2", Name = "Fail test", Enterprise = "zb", Site = "dev",
|
||||
NodeCount = 1, RedundancyMode = RedundancyMode.None, Enabled = true, CreatedBy = "t",
|
||||
});
|
||||
db.ClusterNodes.Add(new ClusterNode
|
||||
{
|
||||
NodeId = "p-2-a", ClusterId = "p-2", RedundancyRole = RedundancyRole.Primary,
|
||||
Host = "localhost", OpcUaPort = 4840, DashboardPort = 5001,
|
||||
ApplicationUri = "urn:p2:test", ServiceLevelBase = 200, Enabled = true, CreatedBy = "t",
|
||||
});
|
||||
db.ClusterNodeGenerationStates.Add(new ClusterNodeGenerationState
|
||||
{
|
||||
NodeId = "p-2-a",
|
||||
LastAppliedStatus = NodeApplyStatus.Failed,
|
||||
LastAppliedError = "simulated",
|
||||
LastAppliedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var alerts = new RecordingHubClients();
|
||||
var alertHub = new RecordingHubContext<AlertHub>(alerts);
|
||||
var fleetHub = new RecordingHubContext<FleetStatusHub>(new RecordingHubClients());
|
||||
|
||||
var poller = new FleetStatusPoller(
|
||||
_sp.GetRequiredService<IServiceScopeFactory>(),
|
||||
fleetHub, alertHub, NullLogger<FleetStatusPoller>.Instance);
|
||||
|
||||
await poller.PollOnceAsync(CancellationToken.None);
|
||||
|
||||
var alertMatch = alerts.SentMessages.FirstOrDefault(m =>
|
||||
m.Method == "AlertRaised" &&
|
||||
m.Args.Length > 0 &&
|
||||
m.Args[0] is AlertMessage alert && alert.NodeId == "p-2-a" && alert.Severity == "error");
|
||||
alertMatch.ShouldNotBeNull("poller should have raised AlertRaised for p-2-a");
|
||||
}
|
||||
}
|
||||
45
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/LdapAuthServiceTests.cs
Normal file
45
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/LdapAuthServiceTests.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System.Reflection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic unit tests for the LDAP input-sanitization and DN-parsing helpers. Live LDAP
|
||||
/// bind against the GLAuth dev instance is covered by the admin-browser smoke path, not here,
|
||||
/// because unit runs must not depend on a running external service.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class LdapAuthServiceTests
|
||||
{
|
||||
private static string EscapeLdapFilter(string input) =>
|
||||
(string)typeof(LdapAuthService)
|
||||
.GetMethod("EscapeLdapFilter", BindingFlags.NonPublic | BindingFlags.Static)!
|
||||
.Invoke(null, [input])!;
|
||||
|
||||
private static string ExtractFirstRdnValue(string dn) =>
|
||||
(string)typeof(LdapAuthService)
|
||||
.GetMethod("ExtractFirstRdnValue", BindingFlags.NonPublic | BindingFlags.Static)!
|
||||
.Invoke(null, [dn])!;
|
||||
|
||||
[Theory]
|
||||
[InlineData("alice", "alice")]
|
||||
[InlineData("a(b)c", "a\\28b\\29c")]
|
||||
[InlineData("wildcard*", "wildcard\\2a")]
|
||||
[InlineData("back\\slash", "back\\5cslash")]
|
||||
public void Escape_filter_replaces_control_chars(string input, string expected)
|
||||
{
|
||||
EscapeLdapFilter(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ou=ReadOnly,ou=groups,dc=lmxopcua,dc=local", "ReadOnly")]
|
||||
[InlineData("cn=admin,dc=corp,dc=com", "admin")]
|
||||
[InlineData("ReadOnly", "ReadOnly")] // no '=' → pass through
|
||||
[InlineData("ou=OnlySegment", "OnlySegment")]
|
||||
public void Extract_first_RDN_strips_the_first_attribute_value(string dn, string expected)
|
||||
{
|
||||
ExtractFirstRdnValue(dn).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
77
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/LdapLiveBindTests.cs
Normal file
77
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/LdapLiveBindTests.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Live-service tests against the dev GLAuth instance at <c>localhost:3893</c>. Skipped when
|
||||
/// the port is unreachable so the test suite stays portable. Verifies the bind path —
|
||||
/// group/role resolution is covered deterministically by <see cref="RoleMapperTests"/>,
|
||||
/// <see cref="LdapAuthServiceTests"/>, and varies per directory (GLAuth, OpenLDAP, AD emit
|
||||
/// <c>memberOf</c> differently; the service has a DN-based fallback for the GLAuth case).
|
||||
/// </summary>
|
||||
[Trait("Category", "LiveLdap")]
|
||||
public sealed class LdapLiveBindTests
|
||||
{
|
||||
private static bool GlauthReachable()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var task = client.ConnectAsync("localhost", 3893);
|
||||
return task.Wait(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static LdapAuthService NewService() => new(Options.Create(new LdapOptions
|
||||
{
|
||||
Server = "localhost",
|
||||
Port = 3893,
|
||||
UseTls = false,
|
||||
AllowInsecureLdap = true,
|
||||
SearchBase = "dc=lmxopcua,dc=local",
|
||||
ServiceAccountDn = "", // direct-bind: GLAuth's nameformat=cn + baseDN means user DN is cn={name},{baseDN}
|
||||
GroupToRole = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["ReadOnly"] = "ConfigViewer",
|
||||
["WriteOperate"] = "ConfigEditor",
|
||||
["AlarmAck"] = "FleetAdmin",
|
||||
},
|
||||
}), NullLogger<LdapAuthService>.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task Valid_credentials_bind_successfully()
|
||||
{
|
||||
if (!GlauthReachable()) return;
|
||||
|
||||
var result = await NewService().AuthenticateAsync("readonly", "readonly123");
|
||||
|
||||
result.Success.ShouldBeTrue(result.Error);
|
||||
result.Username.ShouldBe("readonly");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wrong_password_fails_bind()
|
||||
{
|
||||
if (!GlauthReachable()) return;
|
||||
|
||||
var result = await NewService().AuthenticateAsync("readonly", "wrong-pw");
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldContain("Invalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_username_is_rejected_before_hitting_the_directory()
|
||||
{
|
||||
// Doesn't need GLAuth — pre-flight validation in the service.
|
||||
var result = await NewService().AuthenticateAsync("", "anything");
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldContain("required", Case.Insensitive);
|
||||
}
|
||||
}
|
||||
44
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/RecordingHubContext.cs
Normal file
44
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/RecordingHubContext.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal in-memory <see cref="IHubContext{THub}"/> that captures SendAsync invocations for
|
||||
/// assertion. Only the methods the <c>FleetStatusPoller</c> actually calls are implemented —
|
||||
/// other interface surface throws to fail fast if the poller evolves new dependencies.
|
||||
/// </summary>
|
||||
public sealed class RecordingHubContext<THub> : IHubContext<THub> where THub : Hub
|
||||
{
|
||||
public RecordingHubContext(RecordingHubClients clients) => Clients = clients;
|
||||
|
||||
public IHubClients Clients { get; }
|
||||
public IGroupManager Groups => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public sealed class RecordingHubClients : IHubClients
|
||||
{
|
||||
public readonly List<RecordedMessage> SentMessages = [];
|
||||
|
||||
public IClientProxy All => NotUsed();
|
||||
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => NotUsed();
|
||||
public IClientProxy Client(string connectionId) => NotUsed();
|
||||
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => NotUsed();
|
||||
public IClientProxy Group(string groupName) => new RecordingClientProxy(groupName, SentMessages);
|
||||
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => NotUsed();
|
||||
public IClientProxy Groups(IReadOnlyList<string> groupNames) => NotUsed();
|
||||
public IClientProxy User(string userId) => NotUsed();
|
||||
public IClientProxy Users(IReadOnlyList<string> userIds) => NotUsed();
|
||||
|
||||
private static IClientProxy NotUsed() => throw new NotImplementedException("not used by FleetStatusPoller");
|
||||
}
|
||||
|
||||
public sealed class RecordingClientProxy(string target, List<RecordedMessage> sink) : IClientProxy
|
||||
{
|
||||
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||
{
|
||||
sink.Add(new RecordedMessage(target, method, args));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RecordedMessage(string Target, string Method, object?[] Args);
|
||||
61
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/RoleMapperTests.cs
Normal file
61
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/RoleMapperTests.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RoleMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Maps_single_group_to_single_role()
|
||||
{
|
||||
var mapping = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["ReadOnly"] = "ConfigViewer",
|
||||
};
|
||||
RoleMapper.Map(["ReadOnly"], mapping).ShouldBe(["ConfigViewer"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Group_match_is_case_insensitive()
|
||||
{
|
||||
var mapping = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["ReadOnly"] = "ConfigViewer",
|
||||
};
|
||||
RoleMapper.Map(["readonly"], mapping).ShouldContain("ConfigViewer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void User_with_multiple_matching_groups_gets_all_distinct_roles()
|
||||
{
|
||||
var mapping = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["ReadOnly"] = "ConfigViewer",
|
||||
["ReadWrite"] = "ConfigEditor",
|
||||
["AlarmAck"] = "FleetAdmin",
|
||||
};
|
||||
var roles = RoleMapper.Map(["ReadOnly", "ReadWrite", "AlarmAck"], mapping);
|
||||
roles.ShouldContain("ConfigViewer");
|
||||
roles.ShouldContain("ConfigEditor");
|
||||
roles.ShouldContain("FleetAdmin");
|
||||
roles.Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_group_is_ignored()
|
||||
{
|
||||
var mapping = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["ReadOnly"] = "ConfigViewer",
|
||||
};
|
||||
RoleMapper.Map(["UnrelatedGroup"], mapping).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_mapping_returns_empty_roles()
|
||||
{
|
||||
RoleMapper.Map(["ReadOnly"], new Dictionary<string, string>()).ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user