From 4879c4e01e1ff5fbbfabda4d4a99fb09572a4ea6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 17 Mar 2026 10:03:06 -0400 Subject: [PATCH] Fix auth, Bootstrap, Blazor nav, LDAP, and deployment pipeline for working Central UI Bootstrap served locally with absolute paths and . LDAP auth uses search-then-bind with service account for GLAuth compatibility. CookieAuthenticationStateProvider reads HttpContext.User instead of parsing JWT. Login/logout forms opt out of Blazor enhanced nav (data-enhance="false"). Nav links use absolute paths; seed data includes Design/Deployment group mappings. DataConnections page loads all connections (not just site-assigned). Site appsettings configured for Test Plant A; Site registers with Central on startup. DeploymentService resolves string site identifier for Akka routing. Instances page gains Create Instance form. --- .../Auth/CookieAuthenticationStateProvider.cs | 41 ++------- .../Components/Layout/NavMenu.razor | 32 +++---- .../Pages/Admin/DataConnections.razor | 8 +- .../Pages/Deployment/Instances.razor | 90 +++++++++++++++++++ .../Components/Pages/Login.razor | 2 +- .../Repositories/ISiteRepository.cs | 1 + .../CommunicationOptions.cs | 6 ++ .../Configurations/SecurityConfiguration.cs | 8 +- .../Repositories/SiteRepository.cs | 5 ++ .../DeploymentService.cs | 21 ++++- .../Actors/AkkaHostedService.cs | 16 ++++ src/ScadaLink.Host/Components/App.razor | 12 +-- src/ScadaLink.Host/Program.cs | 7 ++ src/ScadaLink.Host/appsettings.Central.json | 2 + src/ScadaLink.Host/appsettings.Site.json | 12 +-- .../lib/bootstrap/css/bootstrap.min.css | 6 ++ .../lib/bootstrap/js/bootstrap.bundle.min.js | 7 ++ src/ScadaLink.Security/LdapAuthService.cs | 64 ++++++++++--- src/ScadaLink.Security/SecurityOptions.cs | 12 +++ .../DeploymentServiceTests.cs | 3 +- .../AuthFlowTests.cs | 2 +- 21 files changed, 265 insertions(+), 92 deletions(-) create mode 100644 src/ScadaLink.Host/wwwroot/lib/bootstrap/css/bootstrap.min.css create mode 100644 src/ScadaLink.Host/wwwroot/lib/bootstrap/js/bootstrap.bundle.min.js diff --git a/src/ScadaLink.CentralUI/Auth/CookieAuthenticationStateProvider.cs b/src/ScadaLink.CentralUI/Auth/CookieAuthenticationStateProvider.cs index d0b44c3..80e78ec 100644 --- a/src/ScadaLink.CentralUI/Auth/CookieAuthenticationStateProvider.cs +++ b/src/ScadaLink.CentralUI/Auth/CookieAuthenticationStateProvider.cs @@ -2,55 +2,28 @@ using System.Security.Claims; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Server; using Microsoft.AspNetCore.Http; -using ScadaLink.Security; namespace ScadaLink.CentralUI.Auth; /// -/// Reads the JWT from an HTTP-only cookie and creates a ClaimsPrincipal for Blazor Server. -/// This bridges cookie-based auth (set by the login endpoint) with Blazor's auth state. +/// Bridges ASP.NET Core cookie authentication with Blazor Server's auth state. +/// The cookie middleware has already validated and decrypted the cookie by the time +/// the Blazor circuit is established, so we just read HttpContext.User. /// public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvider { - public const string AuthCookieName = "ScadaLink.Auth"; - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly JwtTokenService _jwtTokenService; - public CookieAuthenticationStateProvider( - IHttpContextAccessor httpContextAccessor, - JwtTokenService jwtTokenService) + public CookieAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; - _jwtTokenService = jwtTokenService; } public override Task GetAuthenticationStateAsync() { - var httpContext = _httpContextAccessor.HttpContext; - if (httpContext == null) - { - return Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()))); - } + var user = _httpContextAccessor.HttpContext?.User + ?? new ClaimsPrincipal(new ClaimsIdentity()); - var token = httpContext.Request.Cookies[AuthCookieName]; - if (string.IsNullOrEmpty(token)) - { - return Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()))); - } - - var principal = _jwtTokenService.ValidateToken(token); - if (principal == null) - { - return Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()))); - } - - // Check idle timeout - if (_jwtTokenService.IsIdleTimedOut(principal)) - { - return Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()))); - } - - return Task.FromResult(new AuthenticationState(principal)); + return Task.FromResult(new AuthenticationState(user)); } } diff --git a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor index 26f80b8..297c058 100644 --- a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor +++ b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor @@ -15,19 +15,19 @@ @@ -37,13 +37,13 @@ @@ -53,13 +53,13 @@ @@ -67,20 +67,20 @@ @* Monitoring — visible to all authenticated users *@ @* Audit Log — Admin only *@ @@ -92,7 +92,7 @@
@context.User.FindFirst("DisplayName")?.Value -
+
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor index 687f566..5d9183e 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor @@ -199,17 +199,15 @@ try { _sites = (await SiteRepository.GetAllSitesAsync()).ToList(); + _connections = (await SiteRepository.GetAllDataConnectionsAsync()).ToList(); - // Load all connections by iterating all sites and collecting unique connections - var allConnections = new Dictionary(); + // Load site assignments for each connection _connectionSites.Clear(); - foreach (var site in _sites) { var siteConns = await SiteRepository.GetDataConnectionsBySiteIdAsync(site.Id); foreach (var conn in siteConns) { - allConnections[conn.Id] = conn; if (!_connectionSites.ContainsKey(conn.Id)) _connectionSites[conn.Id] = new List(); @@ -218,8 +216,6 @@ _connectionSites[conn.Id].Add(assignment); } } - - _connections = allConnections.Values.OrderBy(c => c.Name).ToList(); } catch (Exception ex) { diff --git a/src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor b/src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor index d047edc..21c14d9 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor @@ -7,15 +7,18 @@ @using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.Commons.Types.Enums @using ScadaLink.DeploymentManager +@using ScadaLink.TemplateEngine.Services @attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)] @inject ITemplateEngineRepository TemplateEngineRepository @inject ISiteRepository SiteRepository @inject IDeploymentManagerRepository DeploymentManagerRepository @inject DeploymentService DeploymentService +@inject InstanceService InstanceService

Instances

+
@@ -31,6 +34,49 @@ } else { + @if (_showCreateForm) + { +
+
+
Create Instance
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ @if (_createError != null) + { +
@_createError
+ } +
+
+ } + @* Filters *@
@@ -365,4 +411,48 @@ } _actionInProgress = false; } + + // Create instance form + private bool _showCreateForm; + private string _createName = string.Empty; + private int _createTemplateId; + private int _createSiteId; + private string? _createError; + + private void ShowCreateForm() + { + _createName = string.Empty; + _createTemplateId = 0; + _createSiteId = 0; + _createError = null; + _showCreateForm = true; + } + + private async Task CreateInstance() + { + _createError = null; + if (string.IsNullOrWhiteSpace(_createName)) { _createError = "Instance name is required."; return; } + if (_createTemplateId == 0) { _createError = "Select a template."; return; } + if (_createSiteId == 0) { _createError = "Select a site."; return; } + + try + { + var result = await InstanceService.CreateInstanceAsync( + _createName.Trim(), _createTemplateId, _createSiteId, null, "system"); + if (result.IsSuccess) + { + _showCreateForm = false; + _toast.ShowSuccess($"Instance '{_createName}' created."); + await LoadDataAsync(); + } + else + { + _createError = result.Error; + } + } + catch (Exception ex) + { + _createError = $"Create failed: {ex.Message}"; + } + } } diff --git a/src/ScadaLink.CentralUI/Components/Pages/Login.razor b/src/ScadaLink.CentralUI/Components/Pages/Login.razor index 4e30c6f..774c8fe 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Login.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Login.razor @@ -12,7 +12,7 @@ } -
+
GetDataConnectionByIdAsync(int id, CancellationToken cancellationToken = default); + Task> GetAllDataConnectionsAsync(CancellationToken cancellationToken = default); Task> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default); Task AddDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default); Task UpdateDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default); diff --git a/src/ScadaLink.Communication/CommunicationOptions.cs b/src/ScadaLink.Communication/CommunicationOptions.cs index d13945b..1966565 100644 --- a/src/ScadaLink.Communication/CommunicationOptions.cs +++ b/src/ScadaLink.Communication/CommunicationOptions.cs @@ -27,6 +27,12 @@ public class CommunicationOptions /// Timeout for health report acknowledgement (fire-and-forget, but bounded). public TimeSpan HealthReportTimeout { get; set; } = TimeSpan.FromSeconds(10); + /// + /// Remote actor path for the central communication actor. Used by site nodes to + /// register with central on startup (e.g. "akka.tcp://scadalink@central:8081/user/central-communication"). + /// + public string? CentralActorPath { get; set; } + /// Akka.Remote transport heartbeat interval. public TimeSpan TransportHeartbeatInterval { get; set; } = TimeSpan.FromSeconds(5); diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/SecurityConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/SecurityConfiguration.cs index ba0f24b..bc4e968 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/SecurityConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/SecurityConfiguration.cs @@ -21,8 +21,12 @@ public class LdapGroupMappingConfiguration : IEntityTypeConfiguration m.LdapGroupName).IsUnique(); - // Seed default admin mapping - builder.HasData(new LdapGroupMapping("SCADA-Admins", "Admin") { Id = 1 }); + // Seed default group mappings matching GLAuth test users + builder.HasData( + new LdapGroupMapping("SCADA-Admins", "Admin") { Id = 1 }, + new LdapGroupMapping("SCADA-Designers", "Design") { Id = 2 }, + new LdapGroupMapping("SCADA-Deploy-All", "Deployment") { Id = 3 }, + new LdapGroupMapping("SCADA-Deploy-SiteA", "Deployment") { Id = 4 }); } } diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/SiteRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/SiteRepository.cs index 93bdb56..6a61474 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/SiteRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/SiteRepository.cs @@ -69,6 +69,11 @@ public class SiteRepository : ISiteRepository return await _dbContext.DataConnections.FindAsync([id], cancellationToken); } + public async Task> GetAllDataConnectionsAsync(CancellationToken cancellationToken = default) + { + return await _dbContext.DataConnections.OrderBy(c => c.Name).ToListAsync(cancellationToken); + } + public async Task> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) { var connectionIds = await _dbContext.SiteDataConnectionAssignments diff --git a/src/ScadaLink.DeploymentManager/DeploymentService.cs b/src/ScadaLink.DeploymentManager/DeploymentService.cs index 83cd204..5f29951 100644 --- a/src/ScadaLink.DeploymentManager/DeploymentService.cs +++ b/src/ScadaLink.DeploymentManager/DeploymentService.cs @@ -35,6 +35,7 @@ namespace ScadaLink.DeploymentManager; public class DeploymentService { private readonly IDeploymentManagerRepository _repository; + private readonly ISiteRepository _siteRepository; private readonly IFlatteningPipeline _flatteningPipeline; private readonly CommunicationService _communicationService; private readonly OperationLockManager _lockManager; @@ -44,6 +45,7 @@ public class DeploymentService public DeploymentService( IDeploymentManagerRepository repository, + ISiteRepository siteRepository, IFlatteningPipeline flatteningPipeline, CommunicationService communicationService, OperationLockManager lockManager, @@ -52,6 +54,7 @@ public class DeploymentService ILogger logger) { _repository = repository; + _siteRepository = siteRepository; _flatteningPipeline = flatteningPipeline; _communicationService = communicationService; _lockManager = lockManager; @@ -60,6 +63,16 @@ public class DeploymentService _logger = logger; } + /// + /// Resolves the site's string identifier from the numeric DB ID. + /// The communication layer routes by string identifier (e.g. "site-a"), not DB ID. + /// + private async Task ResolveSiteIdentifierAsync(int siteId, CancellationToken cancellationToken) + { + var site = await _siteRepository.GetSiteByIdAsync(siteId, cancellationToken); + return site?.SiteIdentifier ?? siteId.ToString(); + } + /// /// WP-1: Deploy an instance to its site. /// WP-2: Generates unique deployment ID, computes revision hash. @@ -128,7 +141,7 @@ public class DeploymentService try { // WP-1: Send to site via CommunicationService - var siteId = instance.SiteId.ToString(); + var siteId = await ResolveSiteIdentifierAsync(instance.SiteId, cancellationToken); var command = new DeployInstanceCommand( deploymentId, instance.UniqueName, revisionHash, configJson, user, DateTimeOffset.UtcNow); @@ -206,7 +219,7 @@ public class DeploymentService instance.UniqueName, _options.OperationLockTimeout, cancellationToken); var commandId = Guid.NewGuid().ToString("N"); - var siteId = instance.SiteId.ToString(); + var siteId = await ResolveSiteIdentifierAsync(instance.SiteId, cancellationToken); var command = new DisableInstanceCommand(commandId, instance.UniqueName, DateTimeOffset.UtcNow); var response = await _communicationService.DisableInstanceAsync(siteId, command, cancellationToken); @@ -247,7 +260,7 @@ public class DeploymentService instance.UniqueName, _options.OperationLockTimeout, cancellationToken); var commandId = Guid.NewGuid().ToString("N"); - var siteId = instance.SiteId.ToString(); + var siteId = await ResolveSiteIdentifierAsync(instance.SiteId, cancellationToken); var command = new EnableInstanceCommand(commandId, instance.UniqueName, DateTimeOffset.UtcNow); var response = await _communicationService.EnableInstanceAsync(siteId, command, cancellationToken); @@ -289,7 +302,7 @@ public class DeploymentService instance.UniqueName, _options.OperationLockTimeout, cancellationToken); var commandId = Guid.NewGuid().ToString("N"); - var siteId = instance.SiteId.ToString(); + var siteId = await ResolveSiteIdentifierAsync(instance.SiteId, cancellationToken); var command = new DeleteInstanceCommand(commandId, instance.UniqueName, DateTimeOffset.UtcNow); var response = await _communicationService.DeleteInstanceAsync(siteId, command, cancellationToken); diff --git a/src/ScadaLink.Host/Actors/AkkaHostedService.cs b/src/ScadaLink.Host/Actors/AkkaHostedService.cs index e0f18b0..3af7958 100644 --- a/src/ScadaLink.Host/Actors/AkkaHostedService.cs +++ b/src/ScadaLink.Host/Actors/AkkaHostedService.cs @@ -232,5 +232,21 @@ akka {{ _logger.LogInformation( "Site actors registered. DeploymentManager singleton scoped to role={SiteRole}, SiteCommunicationActor created.", siteRole); + + // Register with Central if configured — tells Central where to send deployment commands + if (!string.IsNullOrWhiteSpace(_communicationOptions.CentralActorPath)) + { + var siteCommActor = _actorSystem.ActorSelection("/user/site-communication"); + siteCommActor.Tell(new RegisterCentralPath(_communicationOptions.CentralActorPath)); + + // Also register this site with Central so it knows our address + var centralSelection = _actorSystem.ActorSelection(_communicationOptions.CentralActorPath); + var localSiteCommPath = $"akka.tcp://scadalink@{_nodeOptions.NodeHostname}:{_nodeOptions.RemotingPort}/user/site-communication"; + centralSelection.Tell(new RegisterSite(_nodeOptions.SiteId!, localSiteCommPath)); + + _logger.LogInformation( + "Registered with Central at {CentralPath} as site {SiteId}", + _communicationOptions.CentralActorPath, _nodeOptions.SiteId); + } } } diff --git a/src/ScadaLink.Host/Components/App.razor b/src/ScadaLink.Host/Components/App.razor index ad950ba..752a9d6 100644 --- a/src/ScadaLink.Host/Components/App.razor +++ b/src/ScadaLink.Host/Components/App.razor @@ -3,11 +3,9 @@ + ScadaLink - +