Add authentication and role-based write access control

Implements configurable user authentication (anonymous + username/password)
with pluggable credential provider (IUserAuthenticationProvider). Anonymous
writes can be disabled via AnonymousCanWrite setting while reads remain
open. Adds -U/-P flags to all CLI commands for authenticated sessions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-27 02:14:37 -04:00
parent b27d355763
commit bbd043e97b
24 changed files with 499 additions and 34 deletions

View File

@@ -29,5 +29,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
/// Gets or sets the Wonderware Historian connection settings used to serve OPC UA historical data.
/// </summary>
public HistorianConfiguration Historian { get; set; } = new HistorianConfiguration();
/// <summary>
/// Gets or sets the authentication and role-based access control settings.
/// </summary>
public AuthenticationConfiguration Authentication { get; set; } = new AuthenticationConfiguration();
}
}

View File

@@ -0,0 +1,42 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
/// <summary>
/// Authentication and role-based access control settings for the OPC UA server.
/// </summary>
public class AuthenticationConfiguration
{
/// <summary>
/// Gets or sets a value indicating whether anonymous OPC UA connections are accepted.
/// </summary>
public bool AllowAnonymous { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether anonymous users can write tag values.
/// When false, only authenticated users can write. Existing security classification restrictions still apply.
/// </summary>
public bool AnonymousCanWrite { get; set; } = true;
/// <summary>
/// Gets or sets the list of username/password pairs accepted for authenticated access.
/// </summary>
public List<UserCredential> Users { get; set; } = new List<UserCredential>();
}
/// <summary>
/// A username/password pair for OPC UA user authentication.
/// </summary>
public class UserCredential
{
/// <summary>
/// Gets or sets the username.
/// </summary>
public string Username { get; set; } = "";
/// <summary>
/// Gets or sets the password.
/// </summary>
public string Password { get; set; } = "";
}
}

View File

@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using System.Linq;
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// Validates credentials against a static list from appsettings.json configuration.
/// </summary>
public class ConfigUserAuthenticationProvider : IUserAuthenticationProvider
{
private readonly Dictionary<string, string> _users;
public ConfigUserAuthenticationProvider(List<UserCredential> users)
{
_users = users.ToDictionary(u => u.Username, u => u.Password, StringComparer.OrdinalIgnoreCase);
}
public bool ValidateCredentials(string username, string password)
{
return _users.TryGetValue(username, out var expected) && expected == password;
}
}
}

View File

@@ -0,0 +1,13 @@
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// Pluggable interface for validating user credentials. Implement for different backing stores (config file, LDAP, etc.).
/// </summary>
public interface IUserAuthenticationProvider
{
/// <summary>
/// Validates a username/password combination.
/// </summary>
bool ValidateCredentials(string username, string password);
}
}

View File

@@ -24,6 +24,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private readonly PerformanceMetrics _metrics;
private readonly HistorianDataSource? _historianDataSource;
private readonly bool _alarmTrackingEnabled;
private readonly bool _anonymousCanWrite;
private readonly string _namespaceUri;
// NodeId → full_tag_reference for read/write resolution
@@ -190,7 +191,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
IMxAccessClient mxAccessClient,
PerformanceMetrics metrics,
HistorianDataSource? historianDataSource = null,
bool alarmTrackingEnabled = false)
bool alarmTrackingEnabled = false,
bool anonymousCanWrite = true)
: base(server, configuration, namespaceUri)
{
_namespaceUri = namespaceUri;
@@ -198,6 +200,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
_metrics = metrics;
_historianDataSource = historianDataSource;
_alarmTrackingEnabled = alarmTrackingEnabled;
_anonymousCanWrite = anonymousCanWrite;
// Wire up data change delivery
_mxAccessClient.OnTagValueChanged += OnMxAccessDataChange;
@@ -378,7 +381,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
new LocalizedText("en", alarmAttr.AttributeName + " Alarm"),
true);
condition.SourceNode.Value = sourceNodeId;
condition.SourceName.Value = alarmAttr.AttributeName;
condition.SourceName.Value = alarmAttr.FullTagReference.TrimEnd('[', ']');
condition.ConditionName.Value = alarmAttr.AttributeName;
condition.AutoReportStateChanges = true;
@@ -833,7 +836,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
new LocalizedText("en", alarmAttr.AttributeName + " Alarm"),
true);
condition.SourceNode.Value = sourceNodeId;
condition.SourceName.Value = alarmAttr.AttributeName;
condition.SourceName.Value = alarmAttr.FullTagReference.TrimEnd('[', ']');
condition.ConditionName.Value = alarmAttr.AttributeName;
condition.AutoReportStateChanges = true;
condition.SetEnableState(SystemContext, true);
@@ -1100,6 +1103,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
if (errors[i] != null && errors[i].StatusCode == StatusCodes.BadNotWritable)
continue;
// Enforce role-based write access: reject anonymous writes when AnonymousCanWrite is false
if (!_anonymousCanWrite && context.UserIdentity?.GrantedRoleIds != null &&
!context.UserIdentity.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_AuthenticatedUser))
{
errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied);
continue;
}
var nodeId = nodesToWrite[i].NodeId;
if (nodeId.NamespaceIndex != NamespaceIndex) continue;

View File

@@ -1,6 +1,9 @@
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;
@@ -8,15 +11,19 @@ using ZB.MOM.WW.LmxOpcUa.Host.Metrics;
namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
{
/// <summary>
/// Custom OPC UA server that creates the LmxNodeManager. (OPC-001, OPC-012)
/// Custom OPC UA server that creates the LmxNodeManager and handles user authentication. (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 LmxNodeManager? _nodeManager;
/// <summary>
@@ -36,34 +43,73 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
}
}
/// <summary>
/// Initializes a custom OPC UA server for the specified Galaxy namespace.
/// </summary>
/// <param name="galaxyName">The Galaxy name used to construct the namespace URI and product URI.</param>
/// <param name="mxAccessClient">The runtime client used by the node manager for live data access.</param>
/// <param name="metrics">The metrics collector shared with the node manager.</param>
/// <param name="historianDataSource">The optional historian adapter used when clients issue OPC UA history reads.</param>
/// <param name="alarmTrackingEnabled">Enables alarm condition tracking for alarm-capable Galaxy attributes.</param>
public LmxOpcUaServer(string galaxyName, IMxAccessClient mxAccessClient, PerformanceMetrics metrics,
HistorianDataSource? historianDataSource = null, bool alarmTrackingEnabled = false)
HistorianDataSource? historianDataSource = null, bool alarmTrackingEnabled = false,
AuthenticationConfiguration? authConfig = null, IUserAuthenticationProvider? authProvider = null)
{
_galaxyName = galaxyName;
_mxAccessClient = mxAccessClient;
_metrics = metrics;
_historianDataSource = historianDataSource;
_alarmTrackingEnabled = alarmTrackingEnabled;
_authConfig = authConfig ?? new AuthenticationConfiguration();
_authProvider = authProvider;
}
/// <inheritdoc />
protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server, ApplicationConfiguration configuration)
{
var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa";
_nodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics, _historianDataSource, _alarmTrackingEnabled);
_nodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics,
_historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite);
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;
}
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");
}
args.Identity = new RoleBasedIdentity(
new UserIdentity(userNameToken),
new List<Role> { Role.AuthenticatedUser });
Log.Information("User {Username} authenticated", userNameToken.UserName);
return;
}
throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected, "Unsupported token type");
}
/// <inheritdoc />
protected override ServerProperties LoadServerProperties()
{

View File

@@ -22,6 +22,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private readonly IMxAccessClient _mxAccessClient;
private readonly PerformanceMetrics _metrics;
private readonly HistorianDataSource? _historianDataSource;
private readonly AuthenticationConfiguration _authConfig;
private readonly IUserAuthenticationProvider? _authProvider;
private ApplicationInstance? _application;
private LmxOpcUaServer? _server;
@@ -48,12 +50,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// <param name="metrics">The metrics collector shared with the node manager and runtime bridge.</param>
/// <param name="historianDataSource">The optional historian adapter that enables OPC UA history read support.</param>
public OpcUaServerHost(OpcUaConfiguration config, IMxAccessClient mxAccessClient, PerformanceMetrics metrics,
HistorianDataSource? historianDataSource = null)
HistorianDataSource? historianDataSource = null,
AuthenticationConfiguration? authConfig = null,
IUserAuthenticationProvider? authProvider = null)
{
_config = config;
_mxAccessClient = mxAccessClient;
_metrics = metrics;
_historianDataSource = historianDataSource;
_authConfig = authConfig ?? new AuthenticationConfiguration();
_authProvider = authProvider;
}
/// <summary>
@@ -84,10 +90,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
SecurityPolicyUri = SecurityPolicies.None
}
},
UserTokenPolicies =
{
new UserTokenPolicy(UserTokenType.Anonymous)
}
UserTokenPolicies = BuildUserTokenPolicies()
},
SecurityConfiguration = new SecurityConfiguration
@@ -160,7 +163,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
certOk = await _application.CheckApplicationInstanceCertificate(false, 2048);
}
_server = new LmxOpcUaServer(_config.GalaxyName, _mxAccessClient, _metrics, _historianDataSource, _config.AlarmTrackingEnabled);
_server = new LmxOpcUaServer(_config.GalaxyName, _mxAccessClient, _metrics, _historianDataSource,
_config.AlarmTrackingEnabled, _authConfig, _authProvider);
await _application.Start(_server);
Log.Information("OPC UA server started on opc.tcp://localhost:{Port}{EndpointPath} (namespace={Namespace})",
@@ -188,6 +192,23 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
}
}
private UserTokenPolicyCollection BuildUserTokenPolicies()
{
var policies = new UserTokenPolicyCollection();
if (_authConfig.AllowAnonymous)
policies.Add(new UserTokenPolicy(UserTokenType.Anonymous));
if (_authConfig.Users.Count > 0)
policies.Add(new UserTokenPolicy(UserTokenType.UserName));
if (policies.Count == 0)
{
Log.Warning("No authentication methods configured — adding Anonymous as fallback");
policies.Add(new UserTokenPolicy(UserTokenType.Anonymous));
}
return policies;
}
/// <summary>
/// Stops the host and releases server resources.
/// </summary>

View File

@@ -55,6 +55,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
configuration.GetSection("GalaxyRepository").Bind(_config.GalaxyRepository);
configuration.GetSection("Dashboard").Bind(_config.Dashboard);
configuration.GetSection("Historian").Bind(_config.Historian);
configuration.GetSection("Authentication").Bind(_config.Authentication);
_mxProxy = new MxProxyAdapter();
_galaxyRepository = new GalaxyRepositoryService(_config.GalaxyRepository);
@@ -156,7 +157,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
var historianDataSource = _config.Historian.Enabled
? new Historian.HistorianDataSource(_config.Historian)
: null;
_serverHost = new OpcUaServerHost(_config.OpcUa, effectiveMxClient, _metrics, historianDataSource);
var authProvider = _config.Authentication.Users.Count > 0
? new Domain.ConfigUserAuthenticationProvider(_config.Authentication.Users)
: (Domain.IUserAuthenticationProvider?)null;
_serverHost = new OpcUaServerHost(_config.OpcUa, effectiveMxClient, _metrics, historianDataSource,
_config.Authentication, authProvider);
// Step 9-10: Query hierarchy, start server, build address space
DateTime? initialDeployTime = null;

View File

@@ -31,6 +31,11 @@
"Port": 8081,
"RefreshIntervalSeconds": 10
},
"Authentication": {
"AllowAnonymous": true,
"AnonymousCanWrite": true,
"Users": []
},
"Historian": {
"Enabled": false,
"ConnectionString": "Server=localhost;Database=Runtime;Integrated Security=true;",