diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor index 7b5ad59..781af6d 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor @@ -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) + { +
+ Live update: @_liveBanner + +
+ }

@_cluster.Name

@@ -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("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(); + } } diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Login.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Login.razor index 18f4408..c85f966 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Login.razor +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Login.razor @@ -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
@@ -15,24 +16,24 @@
- +
- +
@if (_error is not null) {
@_error
} - +

- 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(); + } +}