}
-
+
- Phase 1 note: real LDAP bind is deferred. This scaffold accepts
- any non-empty credentials and issues a FleetAdmin cookie. Replace the
- LdapAuthService stub with the ScadaLink-parity implementation before
- production deployment.
+ LDAP bind against the configured directory. Dev defaults to GLAuth on
+ localhost:3893.
@@ -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
+ {
+ 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
- {
- 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; }
}
}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/AlertHub.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/AlertHub.cs
new file mode 100644
index 0000000..a108115
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/AlertHub.cs
@@ -0,0 +1,31 @@
+using Microsoft.AspNetCore.SignalR;
+
+namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
+
+///
+/// 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 .
+///
+public sealed class AlertHub : Hub
+{
+ public const string AllAlertsGroup = "__alerts__";
+
+ public override async Task OnConnectedAsync()
+ {
+ await Groups.AddToGroupAsync(Context.ConnectionId, AllAlertsGroup);
+ await base.OnConnectedAsync();
+ }
+
+ /// Client-initiated ack. The server side of ack persistence is deferred — v2.1.
+ 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);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/FleetStatusHub.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/FleetStatusHub.cs
new file mode 100644
index 0000000..89d6ef0
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/FleetStatusHub.cs
@@ -0,0 +1,39 @@
+using Microsoft.AspNetCore.SignalR;
+
+namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
+
+///
+/// Pushes per-node generation-apply state changes (ClusterNodeGenerationState) to
+/// subscribed browser clients. Clients call SubscribeCluster(clusterId) on connect to
+/// scope notifications; the server sends NodeStateChanged messages whenever the poller
+/// observes a delta.
+///
+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));
+ }
+
+ /// Clients call this once to also receive fleet-wide status — used by the dashboard.
+ 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);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/FleetStatusPoller.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/FleetStatusPoller.cs
new file mode 100644
index 0000000..bead926
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/FleetStatusPoller.cs
@@ -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;
+
+///
+/// Polls ClusterNodeGenerationState every and publishes
+/// per-node deltas to . Also raises sticky
+/// s on transitions into Failed.
+///
+public sealed class FleetStatusPoller(
+ IServiceScopeFactory scopeFactory,
+ IHubContext fleetHub,
+ IHubContext alertHub,
+ ILogger logger) : BackgroundService
+{
+ public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(5);
+
+ private readonly Dictionary _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();
+
+ 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);
+ }
+ }
+ }
+ }
+
+ /// Exposed for tests — forces a snapshot reset so stub data re-seeds.
+ internal void ResetCache() => _last.Clear();
+
+ private readonly record struct NodeStateSnapshot(
+ string NodeId, string ClusterId, long? GenerationId,
+ string? Status, string? Error, DateTime? AppliedAt, DateTime? SeenAt);
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs
index bcd9e70..0e37fa3 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs
@@ -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();
builder.Services.AddScoped();
builder.Services.AddScoped();
+// LDAP auth — parity with ScadaLink's LdapAuthService (decision #102).
+builder.Services.Configure(
+ builder.Configuration.GetSection("Authentication:Ldap"));
+builder.Services.AddScoped();
+
+// SignalR real-time fleet status + alerts (admin-ui.md §"Real-Time Updates").
+builder.Services.AddHostedService();
+
var app = builder.Build();
app.UseSerilogRequestLogging();
@@ -59,6 +70,9 @@ app.MapPost("/auth/logout", async (HttpContext ctx) =>
ctx.Response.Redirect("/");
});
+app.MapHub("/hubs/fleet");
+app.MapHub("/hubs/alerts");
+
app.MapRazorComponents().AddInteractiveServerRenderMode();
await app.RunAsync();
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Security/ILdapAuthService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Security/ILdapAuthService.cs
new file mode 100644
index 0000000..17f6e00
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Security/ILdapAuthService.cs
@@ -0,0 +1,6 @@
+namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
+
+public interface ILdapAuthService
+{
+ Task AuthenticateAsync(string username, string password, CancellationToken ct = default);
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapAuthResult.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapAuthResult.cs
new file mode 100644
index 0000000..9e7de44
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapAuthResult.cs
@@ -0,0 +1,10 @@
+namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
+
+/// Outcome of an LDAP bind attempt. is the mapped-set of Admin roles.
+public sealed record LdapAuthResult(
+ bool Success,
+ string? DisplayName,
+ string? Username,
+ IReadOnlyList Groups,
+ IReadOnlyList Roles,
+ string? Error);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapAuthService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapAuthService.cs
new file mode 100644
index 0000000..1bf11d0
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapAuthService.cs
@@ -0,0 +1,160 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Novell.Directory.Ldap;
+
+namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
+
+///
+/// LDAP bind-and-search authentication mirrored from ScadaLink's LdapAuthService
+/// (CLAUDE.md memory: scadalink_reference.md) — 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 ).
+///
+public sealed class LdapAuthService(IOptions options, ILogger logger)
+ : ILdapAuthService
+{
+ private readonly LdapOptions _options = options.Value;
+
+ public async Task 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();
+
+ 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 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");
+
+ ///
+ /// Pulls the first ou=Value segment from a DN. GLAuth encodes a user's primary
+ /// group as an ou= RDN immediately above the user's cn=, so this recovers
+ /// the group name when is absent from the entry.
+ ///
+ 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..];
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapOptions.cs
new file mode 100644
index 0000000..4d79474
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapOptions.cs
@@ -0,0 +1,38 @@
+namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
+
+///
+/// LDAP + role-mapping configuration for the Admin UI. Bound from appsettings.json
+/// Authentication:Ldap section. Defaults point at the local GLAuth dev instance (see
+/// C:\publish\glauth\auth.md).
+///
+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; }
+
+ /// Dev-only escape hatch — must be false in production.
+ public bool AllowInsecureLdap { get; set; }
+
+ public string SearchBase { get; set; } = "dc=lmxopcua,dc=local";
+
+ ///
+ /// Service-account DN used for search-then-bind. When empty, a direct-bind with
+ /// cn={user},{SearchBase} is attempted.
+ ///
+ 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";
+
+ ///
+ /// 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:
+ /// "ReadOnly":"ConfigViewer","ReadWrite":"ConfigEditor","AlarmAck":"FleetAdmin"
+ ///
+ public Dictionary GroupToRole { get; set; } = new(StringComparer.OrdinalIgnoreCase);
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Security/RoleMapper.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Security/RoleMapper.cs
new file mode 100644
index 0000000..4b291f7
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Security/RoleMapper.cs
@@ -0,0 +1,23 @@
+namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
+
+///
+/// Deterministic LDAP-group-to-Admin-role mapper driven by .
+/// Every returned role corresponds to a group the user actually holds; no inference.
+///
+public static class RoleMapper
+{
+ public static IReadOnlyList Map(
+ IReadOnlyCollection ldapGroups,
+ IReadOnlyDictionary groupToRole)
+ {
+ if (groupToRole.Count == 0) return [];
+
+ var roles = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var group in ldapGroups)
+ {
+ if (groupToRole.TryGetValue(group, out var role))
+ roles.Add(role);
+ }
+ return [.. roles];
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj b/src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj
index 91eb25a..86778c0 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj
@@ -13,6 +13,8 @@
+
+
@@ -20,6 +22,10 @@
+
+
+
+
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json b/src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json
index 0cd1da8..24d73fd 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json
@@ -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"
}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/FleetStatusPollerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/FleetStatusPollerTests.cs
new file mode 100644
index 0000000..906388d
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/FleetStatusPollerTests.cs
@@ -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(o => o.UseSqlServer(_connectionString));
+ _sp = services.BuildServiceProvider();
+
+ using var scope = _sp.CreateScope();
+ scope.ServiceProvider.GetRequiredService().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();
+ 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(recorder);
+ var alertHub = new RecordingHubContext(new RecordingHubClients());
+
+ var poller = new FleetStatusPoller(
+ _sp.GetRequiredService(),
+ fleetHub, alertHub, NullLogger.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();
+ 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(alerts);
+ var fleetHub = new RecordingHubContext(new RecordingHubClients());
+
+ var poller = new FleetStatusPoller(
+ _sp.GetRequiredService(),
+ fleetHub, alertHub, NullLogger.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");
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/LdapAuthServiceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/LdapAuthServiceTests.cs
new file mode 100644
index 0000000..0364ca6
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/LdapAuthServiceTests.cs
@@ -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;
+
+///
+/// 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.
+///
+[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);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/LdapLiveBindTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/LdapLiveBindTests.cs
new file mode 100644
index 0000000..20a3528
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/LdapLiveBindTests.cs
@@ -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;
+
+///
+/// Live-service tests against the dev GLAuth instance at localhost:3893. Skipped when
+/// the port is unreachable so the test suite stays portable. Verifies the bind path —
+/// group/role resolution is covered deterministically by ,
+/// , and varies per directory (GLAuth, OpenLDAP, AD emit
+/// memberOf differently; the service has a DN-based fallback for the GLAuth case).
+///
+[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.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);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/RecordingHubContext.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/RecordingHubContext.cs
new file mode 100644
index 0000000..8c8d51b
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/RecordingHubContext.cs
@@ -0,0 +1,44 @@
+using Microsoft.AspNetCore.SignalR;
+
+namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
+
+///
+/// Minimal in-memory that captures SendAsync invocations for
+/// assertion. Only the methods the FleetStatusPoller actually calls are implemented —
+/// other interface surface throws to fail fast if the poller evolves new dependencies.
+///
+public sealed class RecordingHubContext : IHubContext 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 SentMessages = [];
+
+ public IClientProxy All => NotUsed();
+ public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => NotUsed();
+ public IClientProxy Client(string connectionId) => NotUsed();
+ public IClientProxy Clients(IReadOnlyList connectionIds) => NotUsed();
+ public IClientProxy Group(string groupName) => new RecordingClientProxy(groupName, SentMessages);
+ public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => NotUsed();
+ public IClientProxy Groups(IReadOnlyList groupNames) => NotUsed();
+ public IClientProxy User(string userId) => NotUsed();
+ public IClientProxy Users(IReadOnlyList userIds) => NotUsed();
+
+ private static IClientProxy NotUsed() => throw new NotImplementedException("not used by FleetStatusPoller");
+}
+
+public sealed class RecordingClientProxy(string target, List 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);
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/RoleMapperTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/RoleMapperTests.cs
new file mode 100644
index 0000000..9156919
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/RoleMapperTests.cs
@@ -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(StringComparer.OrdinalIgnoreCase)
+ {
+ ["ReadOnly"] = "ConfigViewer",
+ };
+ RoleMapper.Map(["ReadOnly"], mapping).ShouldBe(["ConfigViewer"]);
+ }
+
+ [Fact]
+ public void Group_match_is_case_insensitive()
+ {
+ var mapping = new Dictionary(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(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(StringComparer.OrdinalIgnoreCase)
+ {
+ ["ReadOnly"] = "ConfigViewer",
+ };
+ RoleMapper.Map(["UnrelatedGroup"], mapping).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Empty_mapping_returns_empty_roles()
+ {
+ RoleMapper.Map(["ReadOnly"], new Dictionary()).ShouldBeEmpty();
+ }
+}