Fix auth, Bootstrap, Blazor nav, LDAP, and deployment pipeline for working Central UI

Bootstrap served locally with absolute paths and <base href="/">.
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.
This commit is contained in:
Joseph Doherty
2026-03-17 10:03:06 -04:00
parent 6fa4c101ab
commit 4879c4e01e
21 changed files with 265 additions and 92 deletions

View File

@@ -2,55 +2,28 @@ using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server; using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using ScadaLink.Security;
namespace ScadaLink.CentralUI.Auth; namespace ScadaLink.CentralUI.Auth;
/// <summary> /// <summary>
/// Reads the JWT from an HTTP-only cookie and creates a ClaimsPrincipal for Blazor Server. /// Bridges ASP.NET Core cookie authentication with Blazor Server's auth state.
/// This bridges cookie-based auth (set by the login endpoint) with Blazor'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.
/// </summary> /// </summary>
public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvider public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvider
{ {
public const string AuthCookieName = "ScadaLink.Auth";
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly JwtTokenService _jwtTokenService;
public CookieAuthenticationStateProvider( public CookieAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor)
IHttpContextAccessor httpContextAccessor,
JwtTokenService jwtTokenService)
{ {
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_jwtTokenService = jwtTokenService;
} }
public override Task<AuthenticationState> GetAuthenticationStateAsync() public override Task<AuthenticationState> GetAuthenticationStateAsync()
{ {
var httpContext = _httpContextAccessor.HttpContext; var user = _httpContextAccessor.HttpContext?.User
if (httpContext == null) ?? new ClaimsPrincipal(new ClaimsIdentity());
{
return Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())));
}
var token = httpContext.Request.Cookies[AuthCookieName]; return Task.FromResult(new AuthenticationState(user));
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));
} }
} }

View File

@@ -15,19 +15,19 @@
<Authorized Context="adminContext"> <Authorized Context="adminContext">
<li class="nav-section-header">Admin</li> <li class="nav-section-header">Admin</li>
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="admin/ldap-mappings">LDAP Mappings</NavLink> <NavLink class="nav-link" href="/admin/ldap-mappings">LDAP Mappings</NavLink>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="admin/sites">Sites</NavLink> <NavLink class="nav-link" href="/admin/sites">Sites</NavLink>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="admin/data-connections">Data Connections</NavLink> <NavLink class="nav-link" href="/admin/data-connections">Data Connections</NavLink>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="admin/areas">Areas</NavLink> <NavLink class="nav-link" href="/admin/areas">Areas</NavLink>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="admin/api-keys">API Keys</NavLink> <NavLink class="nav-link" href="/admin/api-keys">API Keys</NavLink>
</li> </li>
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
@@ -37,13 +37,13 @@
<Authorized Context="designContext"> <Authorized Context="designContext">
<li class="nav-section-header">Design</li> <li class="nav-section-header">Design</li>
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="design/templates">Templates</NavLink> <NavLink class="nav-link" href="/design/templates">Templates</NavLink>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="design/shared-scripts">Shared Scripts</NavLink> <NavLink class="nav-link" href="/design/shared-scripts">Shared Scripts</NavLink>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="design/external-systems">External Systems</NavLink> <NavLink class="nav-link" href="/design/external-systems">External Systems</NavLink>
</li> </li>
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
@@ -53,13 +53,13 @@
<Authorized Context="deploymentContext"> <Authorized Context="deploymentContext">
<li class="nav-section-header">Deployment</li> <li class="nav-section-header">Deployment</li>
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="deployment/instances">Instances</NavLink> <NavLink class="nav-link" href="/deployment/instances">Instances</NavLink>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="deployment/deployments">Deployments</NavLink> <NavLink class="nav-link" href="/deployment/deployments">Deployments</NavLink>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="deployment/debug-view">Debug View</NavLink> <NavLink class="nav-link" href="/deployment/debug-view">Debug View</NavLink>
</li> </li>
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
@@ -67,20 +67,20 @@
@* Monitoring — visible to all authenticated users *@ @* Monitoring — visible to all authenticated users *@
<li class="nav-section-header">Monitoring</li> <li class="nav-section-header">Monitoring</li>
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="monitoring/health">Health Dashboard</NavLink> <NavLink class="nav-link" href="/monitoring/health">Health Dashboard</NavLink>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="monitoring/event-logs">Event Logs</NavLink> <NavLink class="nav-link" href="/monitoring/event-logs">Event Logs</NavLink>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="monitoring/parked-messages">Parked Messages</NavLink> <NavLink class="nav-link" href="/monitoring/parked-messages">Parked Messages</NavLink>
</li> </li>
@* Audit Log — Admin only *@ @* Audit Log — Admin only *@
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin"> <AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
<Authorized Context="auditContext"> <Authorized Context="auditContext">
<li class="nav-item"> <li class="nav-item">
<NavLink class="nav-link" href="monitoring/audit-log">Audit Log</NavLink> <NavLink class="nav-link" href="/monitoring/audit-log">Audit Log</NavLink>
</li> </li>
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
@@ -92,7 +92,7 @@
<Authorized> <Authorized>
<div class="border-top border-secondary p-2"> <div class="border-top border-secondary p-2">
<span class="d-block text-light small px-2">@context.User.FindFirst("DisplayName")?.Value</span> <span class="d-block text-light small px-2">@context.User.FindFirst("DisplayName")?.Value</span>
<form method="post" action="/auth/logout"> <form method="post" action="/auth/logout" data-enhance="false">
<button type="submit" class="btn btn-link btn-sm text-muted text-decoration-none px-2">Sign Out</button> <button type="submit" class="btn btn-link btn-sm text-muted text-decoration-none px-2">Sign Out</button>
</form> </form>
</div> </div>

View File

@@ -199,17 +199,15 @@
try try
{ {
_sites = (await SiteRepository.GetAllSitesAsync()).ToList(); _sites = (await SiteRepository.GetAllSitesAsync()).ToList();
_connections = (await SiteRepository.GetAllDataConnectionsAsync()).ToList();
// Load all connections by iterating all sites and collecting unique connections // Load site assignments for each connection
var allConnections = new Dictionary<int, DataConnection>();
_connectionSites.Clear(); _connectionSites.Clear();
foreach (var site in _sites) foreach (var site in _sites)
{ {
var siteConns = await SiteRepository.GetDataConnectionsBySiteIdAsync(site.Id); var siteConns = await SiteRepository.GetDataConnectionsBySiteIdAsync(site.Id);
foreach (var conn in siteConns) foreach (var conn in siteConns)
{ {
allConnections[conn.Id] = conn;
if (!_connectionSites.ContainsKey(conn.Id)) if (!_connectionSites.ContainsKey(conn.Id))
_connectionSites[conn.Id] = new List<SiteDataConnectionAssignment>(); _connectionSites[conn.Id] = new List<SiteDataConnectionAssignment>();
@@ -218,8 +216,6 @@
_connectionSites[conn.Id].Add(assignment); _connectionSites[conn.Id].Add(assignment);
} }
} }
_connections = allConnections.Values.OrderBy(c => c.Name).ToList();
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -7,15 +7,18 @@
@using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Types.Enums @using ScadaLink.Commons.Types.Enums
@using ScadaLink.DeploymentManager @using ScadaLink.DeploymentManager
@using ScadaLink.TemplateEngine.Services
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)] @attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
@inject ITemplateEngineRepository TemplateEngineRepository @inject ITemplateEngineRepository TemplateEngineRepository
@inject ISiteRepository SiteRepository @inject ISiteRepository SiteRepository
@inject IDeploymentManagerRepository DeploymentManagerRepository @inject IDeploymentManagerRepository DeploymentManagerRepository
@inject DeploymentService DeploymentService @inject DeploymentService DeploymentService
@inject InstanceService InstanceService
<div class="container-fluid mt-3"> <div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Instances</h4> <h4 class="mb-0">Instances</h4>
<button class="btn btn-primary btn-sm" @onclick="ShowCreateForm">Create Instance</button>
</div> </div>
<ToastNotification @ref="_toast" /> <ToastNotification @ref="_toast" />
@@ -31,6 +34,49 @@
} }
else else
{ {
@if (_showCreateForm)
{
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">Create Instance</h6>
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">Instance Name</label>
<input type="text" class="form-control form-control-sm" @bind="_createName" placeholder="e.g. Motor-1" />
</div>
<div class="col-md-3">
<label class="form-label small">Template</label>
<select class="form-select form-select-sm" @bind="_createTemplateId">
<option value="0">Select template...</option>
@foreach (var t in _templates)
{
<option value="@t.Id">@t.Name</option>
}
</select>
</div>
<div class="col-md-2">
<label class="form-label small">Site</label>
<select class="form-select form-select-sm" @bind="_createSiteId">
<option value="0">Select site...</option>
@foreach (var s in _sites)
{
<option value="@s.Id">@s.Name</option>
}
</select>
</div>
<div class="col-md-2">
<button class="btn btn-success btn-sm me-1" @onclick="CreateInstance">Create</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showCreateForm = false">Cancel</button>
</div>
</div>
@if (_createError != null)
{
<div class="text-danger small mt-1">@_createError</div>
}
</div>
</div>
}
@* Filters *@ @* Filters *@
<div class="row mb-3 g-2"> <div class="row mb-3 g-2">
<div class="col-md-2"> <div class="col-md-2">
@@ -365,4 +411,48 @@
} }
_actionInProgress = false; _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}";
}
}
} }

View File

@@ -12,7 +12,7 @@
<div class="alert alert-danger py-2" role="alert">@ErrorMessage</div> <div class="alert alert-danger py-2" role="alert">@ErrorMessage</div>
} }
<form method="post" action="/auth/login"> <form method="post" action="/auth/login" data-enhance="false">
<div class="mb-3"> <div class="mb-3">
<label for="username" class="form-label">Username</label> <label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" <input type="text" class="form-control" id="username" name="username"

View File

@@ -18,6 +18,7 @@ public interface ISiteRepository
// Data Connections // Data Connections
Task<DataConnection?> GetDataConnectionByIdAsync(int id, CancellationToken cancellationToken = default); Task<DataConnection?> GetDataConnectionByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<DataConnection>> GetAllDataConnectionsAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default); Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
Task AddDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default); Task AddDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default);
Task UpdateDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default); Task UpdateDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default);

View File

@@ -27,6 +27,12 @@ public class CommunicationOptions
/// <summary>Timeout for health report acknowledgement (fire-and-forget, but bounded).</summary> /// <summary>Timeout for health report acknowledgement (fire-and-forget, but bounded).</summary>
public TimeSpan HealthReportTimeout { get; set; } = TimeSpan.FromSeconds(10); public TimeSpan HealthReportTimeout { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// 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").
/// </summary>
public string? CentralActorPath { get; set; }
/// <summary>Akka.Remote transport heartbeat interval.</summary> /// <summary>Akka.Remote transport heartbeat interval.</summary>
public TimeSpan TransportHeartbeatInterval { get; set; } = TimeSpan.FromSeconds(5); public TimeSpan TransportHeartbeatInterval { get; set; } = TimeSpan.FromSeconds(5);

View File

@@ -21,8 +21,12 @@ public class LdapGroupMappingConfiguration : IEntityTypeConfiguration<LdapGroupM
builder.HasIndex(m => m.LdapGroupName).IsUnique(); builder.HasIndex(m => m.LdapGroupName).IsUnique();
// Seed default admin mapping // Seed default group mappings matching GLAuth test users
builder.HasData(new LdapGroupMapping("SCADA-Admins", "Admin") { Id = 1 }); 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 });
} }
} }

View File

@@ -69,6 +69,11 @@ public class SiteRepository : ISiteRepository
return await _dbContext.DataConnections.FindAsync([id], cancellationToken); return await _dbContext.DataConnections.FindAsync([id], cancellationToken);
} }
public async Task<IReadOnlyList<DataConnection>> GetAllDataConnectionsAsync(CancellationToken cancellationToken = default)
{
return await _dbContext.DataConnections.OrderBy(c => c.Name).ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) public async Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
{ {
var connectionIds = await _dbContext.SiteDataConnectionAssignments var connectionIds = await _dbContext.SiteDataConnectionAssignments

View File

@@ -35,6 +35,7 @@ namespace ScadaLink.DeploymentManager;
public class DeploymentService public class DeploymentService
{ {
private readonly IDeploymentManagerRepository _repository; private readonly IDeploymentManagerRepository _repository;
private readonly ISiteRepository _siteRepository;
private readonly IFlatteningPipeline _flatteningPipeline; private readonly IFlatteningPipeline _flatteningPipeline;
private readonly CommunicationService _communicationService; private readonly CommunicationService _communicationService;
private readonly OperationLockManager _lockManager; private readonly OperationLockManager _lockManager;
@@ -44,6 +45,7 @@ public class DeploymentService
public DeploymentService( public DeploymentService(
IDeploymentManagerRepository repository, IDeploymentManagerRepository repository,
ISiteRepository siteRepository,
IFlatteningPipeline flatteningPipeline, IFlatteningPipeline flatteningPipeline,
CommunicationService communicationService, CommunicationService communicationService,
OperationLockManager lockManager, OperationLockManager lockManager,
@@ -52,6 +54,7 @@ public class DeploymentService
ILogger<DeploymentService> logger) ILogger<DeploymentService> logger)
{ {
_repository = repository; _repository = repository;
_siteRepository = siteRepository;
_flatteningPipeline = flatteningPipeline; _flatteningPipeline = flatteningPipeline;
_communicationService = communicationService; _communicationService = communicationService;
_lockManager = lockManager; _lockManager = lockManager;
@@ -60,6 +63,16 @@ public class DeploymentService
_logger = logger; _logger = logger;
} }
/// <summary>
/// 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.
/// </summary>
private async Task<string> ResolveSiteIdentifierAsync(int siteId, CancellationToken cancellationToken)
{
var site = await _siteRepository.GetSiteByIdAsync(siteId, cancellationToken);
return site?.SiteIdentifier ?? siteId.ToString();
}
/// <summary> /// <summary>
/// WP-1: Deploy an instance to its site. /// WP-1: Deploy an instance to its site.
/// WP-2: Generates unique deployment ID, computes revision hash. /// WP-2: Generates unique deployment ID, computes revision hash.
@@ -128,7 +141,7 @@ public class DeploymentService
try try
{ {
// WP-1: Send to site via CommunicationService // WP-1: Send to site via CommunicationService
var siteId = instance.SiteId.ToString(); var siteId = await ResolveSiteIdentifierAsync(instance.SiteId, cancellationToken);
var command = new DeployInstanceCommand( var command = new DeployInstanceCommand(
deploymentId, instance.UniqueName, revisionHash, configJson, user, DateTimeOffset.UtcNow); deploymentId, instance.UniqueName, revisionHash, configJson, user, DateTimeOffset.UtcNow);
@@ -206,7 +219,7 @@ public class DeploymentService
instance.UniqueName, _options.OperationLockTimeout, cancellationToken); instance.UniqueName, _options.OperationLockTimeout, cancellationToken);
var commandId = Guid.NewGuid().ToString("N"); 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 command = new DisableInstanceCommand(commandId, instance.UniqueName, DateTimeOffset.UtcNow);
var response = await _communicationService.DisableInstanceAsync(siteId, command, cancellationToken); var response = await _communicationService.DisableInstanceAsync(siteId, command, cancellationToken);
@@ -247,7 +260,7 @@ public class DeploymentService
instance.UniqueName, _options.OperationLockTimeout, cancellationToken); instance.UniqueName, _options.OperationLockTimeout, cancellationToken);
var commandId = Guid.NewGuid().ToString("N"); 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 command = new EnableInstanceCommand(commandId, instance.UniqueName, DateTimeOffset.UtcNow);
var response = await _communicationService.EnableInstanceAsync(siteId, command, cancellationToken); var response = await _communicationService.EnableInstanceAsync(siteId, command, cancellationToken);
@@ -289,7 +302,7 @@ public class DeploymentService
instance.UniqueName, _options.OperationLockTimeout, cancellationToken); instance.UniqueName, _options.OperationLockTimeout, cancellationToken);
var commandId = Guid.NewGuid().ToString("N"); 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 command = new DeleteInstanceCommand(commandId, instance.UniqueName, DateTimeOffset.UtcNow);
var response = await _communicationService.DeleteInstanceAsync(siteId, command, cancellationToken); var response = await _communicationService.DeleteInstanceAsync(siteId, command, cancellationToken);

View File

@@ -232,5 +232,21 @@ akka {{
_logger.LogInformation( _logger.LogInformation(
"Site actors registered. DeploymentManager singleton scoped to role={SiteRole}, SiteCommunicationActor created.", "Site actors registered. DeploymentManager singleton scoped to role={SiteRole}, SiteCommunicationActor created.",
siteRole); 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);
}
} }
} }

View File

@@ -3,11 +3,9 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<title>ScadaLink</title> <title>ScadaLink</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" <link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YcnS/1p0TQGL3BNgcree90f9QM0jB1zDTkM6"
crossorigin="anonymous" />
<style> <style>
.sidebar { .sidebar {
min-width: 220px; min-width: 220px;
@@ -82,7 +80,7 @@
</div> </div>
</div> </div>
<script src="_framework/blazor.web.js"></script> <script src="/_framework/blazor.web.js"></script>
<script> <script>
// Reconnection overlay for failover behavior // Reconnection overlay for failover behavior
if (Blazor) { if (Blazor) {
@@ -91,8 +89,6 @@
}); });
} }
</script> </script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" <script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"></script>
</body> </body>
</html> </html>

View File

@@ -152,6 +152,13 @@ try
services.AddExternalSystemGateway(); services.AddExternalSystemGateway();
services.AddNotificationService(); services.AddNotificationService();
// Configuration database (read-only access for external system definitions, notification lists)
var configDbConnectionString = context.Configuration["ScadaLink:Database:ConfigurationDb"];
if (!string.IsNullOrWhiteSpace(configDbConnectionString))
{
services.AddConfigurationDatabase(configDbConnectionString);
}
// Site-only components — AddSiteRuntime registers SiteStorageService with SQLite path // Site-only components — AddSiteRuntime registers SiteStorageService with SQLite path
var siteDbPath = context.Configuration["ScadaLink:Database:SiteDbPath"] ?? "site.db"; var siteDbPath = context.Configuration["ScadaLink:Database:SiteDbPath"] ?? "site.db";
services.AddSiteRuntime($"Data Source={siteDbPath}"); services.AddSiteRuntime($"Data Source={siteDbPath}");

View File

@@ -26,6 +26,8 @@
"LdapUseTls": false, "LdapUseTls": false,
"AllowInsecureLdap": true, "AllowInsecureLdap": true,
"LdapSearchBase": "dc=scadalink,dc=local", "LdapSearchBase": "dc=scadalink,dc=local",
"LdapServiceAccountDn": "cn=admin,dc=scadalink,dc=local",
"LdapServiceAccountPassword": "password",
"JwtSigningKey": "scadalink-dev-jwt-signing-key-must-be-at-least-32-characters-long", "JwtSigningKey": "scadalink-dev-jwt-signing-key-must-be-at-least-32-characters-long",
"JwtExpiryMinutes": 15, "JwtExpiryMinutes": 15,
"IdleTimeoutMinutes": 30 "IdleTimeoutMinutes": 30

View File

@@ -2,14 +2,14 @@
"ScadaLink": { "ScadaLink": {
"Node": { "Node": {
"Role": "Site", "Role": "Site",
"NodeHostname": "site-a-node1", "NodeHostname": "localhost",
"SiteId": "SiteA", "SiteId": "site-a",
"RemotingPort": 8082 "RemotingPort": 8082
}, },
"Cluster": { "Cluster": {
"SeedNodes": [ "SeedNodes": [
"akka.tcp://scadalink@site-a-node1:8082", "akka.tcp://scadalink@localhost:8082",
"akka.tcp://scadalink@site-a-node2:8082" "akka.tcp://scadalink@localhost:8083"
], ],
"SplitBrainResolverStrategy": "keep-oldest", "SplitBrainResolverStrategy": "keep-oldest",
"StableAfter": "00:00:15", "StableAfter": "00:00:15",
@@ -18,7 +18,8 @@
"MinNrOfMembers": 1 "MinNrOfMembers": 1
}, },
"Database": { "Database": {
"SiteDbPath": "./data/scadalink.db" "SiteDbPath": "./data/scadalink.db",
"ConfigurationDb": "Server=localhost,1433;Database=ScadaLinkConfig;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true"
}, },
"DataConnection": { "DataConnection": {
"ReconnectInterval": "00:00:05", "ReconnectInterval": "00:00:05",
@@ -30,6 +31,7 @@
"ReplicationEnabled": true "ReplicationEnabled": true
}, },
"Communication": { "Communication": {
"CentralActorPath": "akka.tcp://scadalink@localhost:8081/user/central-communication",
"DeploymentTimeout": "00:02:00", "DeploymentTimeout": "00:02:00",
"LifecycleTimeout": "00:00:30", "LifecycleTimeout": "00:00:30",
"QueryTimeout": "00:00:30", "QueryTimeout": "00:00:30",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -46,10 +46,17 @@ public class LdapAuthService
await Task.Run(() => connection.StartTls(), ct); await Task.Run(() => connection.StartTls(), ct);
} }
// Direct bind with user credentials // Resolve the user's actual DN, then bind with their credentials
var bindDn = BuildBindDn(username); var bindDn = await ResolveUserDnAsync(connection, username, ct);
await Task.Run(() => connection.Bind(bindDn, password), ct); await Task.Run(() => connection.Bind(bindDn, password), ct);
// Re-bind as service account for attribute/group lookup (user may lack search rights)
if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn))
{
await Task.Run(() =>
connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct);
}
// Query for user attributes and group memberships // Query for user attributes and group memberships
var displayName = username; var displayName = username;
var groups = new List<string>(); var groups = new List<string>();
@@ -79,7 +86,7 @@ public class LdapAuthService
{ {
foreach (var groupDn in groupAttr.StringValueArray) foreach (var groupDn in groupAttr.StringValueArray)
{ {
groups.Add(ExtractCn(groupDn)); groups.Add(ExtractFirstRdnValue(groupDn));
} }
} }
} }
@@ -112,13 +119,41 @@ public class LdapAuthService
} }
} }
private string BuildBindDn(string username) /// <summary>
/// Resolves the user's full DN. When a service account is configured, performs a
/// search-then-bind lookup. Otherwise falls back to constructing the DN directly.
/// </summary>
private async Task<string> ResolveUserDnAsync(LdapConnection connection, string username, CancellationToken ct)
{ {
// If username already looks like a DN, use it as-is // If username already looks like a DN, use it as-is
if (username.Contains('=')) if (username.Contains('='))
return username; return username;
// Build DN from username and search base // If a service account is configured, search for the user's actual DN
if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn))
{
await Task.Run(() =>
connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct);
var searchFilter = $"(uid={EscapeLdapFilter(username)})";
var searchResults = await Task.Run(() =>
connection.Search(
_options.LdapSearchBase,
LdapConnection.ScopeSub,
searchFilter,
new[] { "dn" },
false), ct);
if (searchResults.HasMore())
{
var entry = searchResults.Next();
return entry.Dn;
}
throw new LdapException("User not found", LdapException.NoSuchObject, $"No entry found for uid={username}");
}
// Fallback: construct DN directly
return string.IsNullOrWhiteSpace(_options.LdapSearchBase) return string.IsNullOrWhiteSpace(_options.LdapSearchBase)
? $"cn={username}" ? $"cn={username}"
: $"cn={username},{_options.LdapSearchBase}"; : $"cn={username},{_options.LdapSearchBase}";
@@ -134,15 +169,16 @@ public class LdapAuthService
.Replace("\0", "\\00"); .Replace("\0", "\\00");
} }
private static string ExtractCn(string dn) private static string ExtractFirstRdnValue(string dn)
{ {
// Extract CN from a DN like "cn=GroupName,dc=example,dc=com" // Extract the value of the first RDN from a DN.
if (dn.StartsWith("cn=", StringComparison.OrdinalIgnoreCase) || // Handles cn=, ou=, or any attribute: "ou=SCADA-Admins,ou=groups,dc=..." → "SCADA-Admins"
dn.StartsWith("CN=", StringComparison.OrdinalIgnoreCase)) var equalsIndex = dn.IndexOf('=');
{ if (equalsIndex < 0)
var commaIndex = dn.IndexOf(','); return dn;
return commaIndex > 3 ? dn[3..commaIndex] : dn[3..];
} var valueStart = equalsIndex + 1;
return dn; var commaIndex = dn.IndexOf(',', valueStart);
return commaIndex > valueStart ? dn[valueStart..commaIndex] : dn[valueStart..];
} }
} }

View File

@@ -17,6 +17,18 @@ public class SecurityOptions
/// </summary> /// </summary>
public string LdapSearchBase { get; set; } = string.Empty; public string LdapSearchBase { get; set; } = string.Empty;
/// <summary>
/// Service account DN for LDAP user searches (e.g., "cn=admin,dc=example,dc=com").
/// Required for search-then-bind authentication. If empty, direct bind with
/// cn={username},{LdapSearchBase} is attempted instead.
/// </summary>
public string LdapServiceAccountDn { get; set; } = string.Empty;
/// <summary>
/// Service account password for LDAP user searches.
/// </summary>
public string LdapServiceAccountPassword { get; set; } = string.Empty;
/// <summary> /// <summary>
/// LDAP attribute that contains the user's display name. /// LDAP attribute that contains the user's display name.
/// </summary> /// </summary>

View File

@@ -41,8 +41,9 @@ public class DeploymentServiceTests
OperationLockTimeout = TimeSpan.FromSeconds(5) OperationLockTimeout = TimeSpan.FromSeconds(5)
}); });
var siteRepo = Substitute.For<ISiteRepository>();
_service = new DeploymentService( _service = new DeploymentService(
_repo, _pipeline, _comms, _lockManager, _audit, options, _repo, siteRepo, _pipeline, _comms, _lockManager, _audit, options,
NullLogger<DeploymentService>.Instance); NullLogger<DeploymentService>.Instance);
} }

View File

@@ -134,7 +134,7 @@ public class AuthFlowTests : IClassFixture<ScadaLinkWebApplicationFactory>
// Verify auth cookie was set // Verify auth cookie was set
var setCookieHeader = response.Headers.GetValues("Set-Cookie").FirstOrDefault(); var setCookieHeader = response.Headers.GetValues("Set-Cookie").FirstOrDefault();
Assert.NotNull(setCookieHeader); Assert.NotNull(setCookieHeader);
Assert.Contains(CookieAuthenticationStateProvider.AuthCookieName, setCookieHeader); Assert.Contains("ScadaLink.Auth", setCookieHeader);
} }
private static async Task<bool> IsLdapAvailableAsync() private static async Task<bool> IsLdapAvailableAsync()