Replace static user list with GLAuth LDAP authentication. Group membership (ReadOnly, ReadWrite, AlarmAck) maps to granular OPC UA permissions for write and alarm-ack operations. Anonymous can still browse and read but not write. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
268 lines
12 KiB
C#
268 lines
12 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Text;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Server;
|
|
using Serilog;
|
|
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
|
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
|
|
using ZB.MOM.WW.LmxOpcUa.Host.Historian;
|
|
using ZB.MOM.WW.LmxOpcUa.Host.Metrics;
|
|
|
|
namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
|
{
|
|
/// <summary>
|
|
/// Custom OPC UA server that creates the LmxNodeManager, handles user authentication,
|
|
/// and exposes redundancy state through the standard server object. (OPC-001, OPC-012)
|
|
/// </summary>
|
|
public class LmxOpcUaServer : StandardServer
|
|
{
|
|
private static readonly ILogger Log = Serilog.Log.ForContext<LmxOpcUaServer>();
|
|
|
|
private readonly string _galaxyName;
|
|
private readonly IMxAccessClient _mxAccessClient;
|
|
private readonly PerformanceMetrics _metrics;
|
|
private readonly HistorianDataSource? _historianDataSource;
|
|
private readonly bool _alarmTrackingEnabled;
|
|
private readonly AuthenticationConfiguration _authConfig;
|
|
private readonly IUserAuthenticationProvider? _authProvider;
|
|
private readonly RedundancyConfiguration _redundancyConfig;
|
|
private readonly string? _applicationUri;
|
|
private readonly ServiceLevelCalculator _serviceLevelCalculator = new ServiceLevelCalculator();
|
|
private readonly ConcurrentDictionary<string, IReadOnlyList<string>> _userAppRoles = new ConcurrentDictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
|
|
private LmxNodeManager? _nodeManager;
|
|
|
|
/// <summary>
|
|
/// Gets the custom node manager that publishes the Galaxy-backed namespace.
|
|
/// </summary>
|
|
public LmxNodeManager? NodeManager => _nodeManager;
|
|
|
|
/// <summary>
|
|
/// Returns the application-level roles cached for the given username, or null if no roles are stored.
|
|
/// Called by LmxNodeManager to enforce per-role write and alarm-ack permissions.
|
|
/// </summary>
|
|
public IReadOnlyList<string>? GetUserAppRoles(string? username)
|
|
{
|
|
if (username != null && _userAppRoles.TryGetValue(username, out var roles))
|
|
return roles;
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets whether LDAP role-based access control is active.
|
|
/// </summary>
|
|
public bool LdapRolesEnabled => _authProvider is Domain.IRoleProvider;
|
|
|
|
/// <summary>
|
|
/// Gets the number of active OPC UA sessions currently connected to the server.
|
|
/// </summary>
|
|
public int ActiveSessionCount
|
|
{
|
|
get
|
|
{
|
|
try { return ServerInternal?.SessionManager?.GetSessions()?.Count ?? 0; }
|
|
catch { return 0; }
|
|
}
|
|
}
|
|
|
|
public LmxOpcUaServer(string galaxyName, IMxAccessClient mxAccessClient, PerformanceMetrics metrics,
|
|
HistorianDataSource? historianDataSource = null, bool alarmTrackingEnabled = false,
|
|
AuthenticationConfiguration? authConfig = null, IUserAuthenticationProvider? authProvider = null,
|
|
RedundancyConfiguration? redundancyConfig = null, string? applicationUri = null)
|
|
{
|
|
_galaxyName = galaxyName;
|
|
_mxAccessClient = mxAccessClient;
|
|
_metrics = metrics;
|
|
_historianDataSource = historianDataSource;
|
|
_alarmTrackingEnabled = alarmTrackingEnabled;
|
|
_authConfig = authConfig ?? new AuthenticationConfiguration();
|
|
_authProvider = authProvider;
|
|
_redundancyConfig = redundancyConfig ?? new RedundancyConfiguration();
|
|
_applicationUri = applicationUri;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server, ApplicationConfiguration configuration)
|
|
{
|
|
var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa";
|
|
_nodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics,
|
|
_historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite,
|
|
LdapRolesEnabled ? (Func<string?, IReadOnlyList<string>?>)GetUserAppRoles : null,
|
|
LdapRolesEnabled);
|
|
|
|
var nodeManagers = new List<INodeManager> { _nodeManager };
|
|
return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray());
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override void OnServerStarted(IServerInternal server)
|
|
{
|
|
base.OnServerStarted(server);
|
|
server.SessionManager.ImpersonateUser += OnImpersonateUser;
|
|
|
|
ConfigureRedundancy(server);
|
|
}
|
|
|
|
private void ConfigureRedundancy(IServerInternal server)
|
|
{
|
|
var mode = RedundancyModeResolver.Resolve(_redundancyConfig.Mode, _redundancyConfig.Enabled);
|
|
|
|
try
|
|
{
|
|
// Set RedundancySupport via the diagnostics node manager
|
|
var redundancySupportNodeId = VariableIds.Server_ServerRedundancy_RedundancySupport;
|
|
var redundancySupportNode = server.DiagnosticsNodeManager?.FindPredefinedNode(
|
|
redundancySupportNodeId, typeof(BaseVariableState)) as BaseVariableState;
|
|
|
|
if (redundancySupportNode != null)
|
|
{
|
|
redundancySupportNode.Value = (int)mode;
|
|
redundancySupportNode.ClearChangeMasks(server.DefaultSystemContext, false);
|
|
Log.Information("Set RedundancySupport to {Mode}", mode);
|
|
}
|
|
|
|
// Set ServerUriArray for non-transparent redundancy
|
|
if (_redundancyConfig.Enabled && _redundancyConfig.ServerUris.Count > 0)
|
|
{
|
|
var serverUriArrayNodeId = VariableIds.Server_ServerRedundancy_ServerUriArray;
|
|
var serverUriArrayNode = server.DiagnosticsNodeManager?.FindPredefinedNode(
|
|
serverUriArrayNodeId, typeof(BaseVariableState)) as BaseVariableState;
|
|
|
|
if (serverUriArrayNode != null)
|
|
{
|
|
serverUriArrayNode.Value = _redundancyConfig.ServerUris.ToArray();
|
|
serverUriArrayNode.ClearChangeMasks(server.DefaultSystemContext, false);
|
|
Log.Information("Set ServerUriArray to [{Uris}]", string.Join(", ", _redundancyConfig.ServerUris));
|
|
}
|
|
else
|
|
{
|
|
Log.Warning("ServerUriArray node not found in address space — SDK may not expose it for RedundancySupport.None base type");
|
|
}
|
|
}
|
|
|
|
// Set initial ServiceLevel
|
|
var initialLevel = CalculateCurrentServiceLevel(true, true);
|
|
SetServiceLevelValue(server, initialLevel);
|
|
Log.Information("Initial ServiceLevel set to {ServiceLevel}", initialLevel);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Warning(ex, "Failed to configure redundancy nodes — redundancy state may not be visible to clients");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the server's ServiceLevel based on current runtime health.
|
|
/// Called by the service layer when MXAccess or DB health changes.
|
|
/// </summary>
|
|
/// <param name="mxAccessConnected">Whether the MXAccess connection is healthy.</param>
|
|
/// <param name="dbConnected">Whether the Galaxy repository database is reachable.</param>
|
|
public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected)
|
|
{
|
|
var level = CalculateCurrentServiceLevel(mxAccessConnected, dbConnected);
|
|
try
|
|
{
|
|
if (ServerInternal != null)
|
|
{
|
|
SetServiceLevelValue(ServerInternal, level);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Debug(ex, "Failed to update ServiceLevel node");
|
|
}
|
|
}
|
|
|
|
private byte CalculateCurrentServiceLevel(bool mxAccessConnected, bool dbConnected)
|
|
{
|
|
if (!_redundancyConfig.Enabled)
|
|
return 255; // SDK default when redundancy is not configured
|
|
|
|
var isPrimary = string.Equals(_redundancyConfig.Role, "Primary", StringComparison.OrdinalIgnoreCase);
|
|
var baseLevel = isPrimary
|
|
? _redundancyConfig.ServiceLevelBase
|
|
: Math.Max(0, _redundancyConfig.ServiceLevelBase - 50);
|
|
|
|
return _serviceLevelCalculator.Calculate(baseLevel, mxAccessConnected, dbConnected);
|
|
}
|
|
|
|
private static void SetServiceLevelValue(IServerInternal server, byte level)
|
|
{
|
|
var serviceLevelNodeId = VariableIds.Server_ServiceLevel;
|
|
var serviceLevelNode = server.DiagnosticsNodeManager?.FindPredefinedNode(
|
|
serviceLevelNodeId, typeof(BaseVariableState)) as BaseVariableState;
|
|
|
|
if (serviceLevelNode != null)
|
|
{
|
|
serviceLevelNode.Value = level;
|
|
serviceLevelNode.ClearChangeMasks(server.DefaultSystemContext, false);
|
|
}
|
|
}
|
|
|
|
private void OnImpersonateUser(Session session, ImpersonateEventArgs args)
|
|
{
|
|
if (args.NewIdentity is AnonymousIdentityToken anonymousToken)
|
|
{
|
|
if (!_authConfig.AllowAnonymous)
|
|
throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected, "Anonymous access is disabled");
|
|
|
|
var roles = new List<Role> { Role.Anonymous };
|
|
if (_authConfig.AnonymousCanWrite)
|
|
roles.Add(Role.AuthenticatedUser);
|
|
|
|
args.Identity = new RoleBasedIdentity(new UserIdentity(anonymousToken), roles);
|
|
Log.Debug("Anonymous session accepted (canWrite={CanWrite})", _authConfig.AnonymousCanWrite);
|
|
return;
|
|
}
|
|
|
|
if (args.NewIdentity is UserNameIdentityToken userNameToken)
|
|
{
|
|
var password = userNameToken.DecryptedPassword ?? "";
|
|
|
|
if (_authProvider == null || !_authProvider.ValidateCredentials(userNameToken.UserName, password))
|
|
{
|
|
Log.Warning("Authentication failed for user {Username}", userNameToken.UserName);
|
|
throw new ServiceResultException(StatusCodes.BadUserAccessDenied, "Invalid username or password");
|
|
}
|
|
|
|
var roles = new List<Role> { Role.AuthenticatedUser };
|
|
|
|
// Resolve LDAP-based roles when the provider supports it
|
|
if (_authProvider is Domain.IRoleProvider roleProvider)
|
|
{
|
|
var appRoles = roleProvider.GetUserRoles(userNameToken.UserName);
|
|
_userAppRoles[userNameToken.UserName] = appRoles;
|
|
Log.Information("User {Username} authenticated with roles [{Roles}]",
|
|
userNameToken.UserName, string.Join(", ", appRoles));
|
|
}
|
|
else
|
|
{
|
|
Log.Information("User {Username} authenticated", userNameToken.UserName);
|
|
}
|
|
|
|
args.Identity = new RoleBasedIdentity(
|
|
new UserIdentity(userNameToken), roles);
|
|
return;
|
|
}
|
|
|
|
throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected, "Unsupported token type");
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override ServerProperties LoadServerProperties()
|
|
{
|
|
var properties = new ServerProperties
|
|
{
|
|
ManufacturerName = "ZB MOM",
|
|
ProductName = "LmxOpcUa Server",
|
|
ProductUri = $"urn:{_galaxyName}:LmxOpcUa",
|
|
SoftwareVersion = GetType().Assembly.GetName().Version?.ToString() ?? "1.0.0",
|
|
BuildNumber = "1",
|
|
BuildDate = System.DateTime.UtcNow
|
|
};
|
|
return properties;
|
|
}
|
|
}
|
|
}
|