Add LDAP authentication with role-based OPC UA permissions
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>
This commit is contained in:
@@ -85,6 +85,10 @@ The server supports configurable OPC UA transport security via the `Security` se
|
||||
|
||||
The server supports non-transparent warm/hot redundancy via the `Redundancy` section in `appsettings.json`. Two instances share the same Galaxy DB and MXAccess runtime but have unique `ApplicationUri` values. Each exposes `RedundancySupport`, `ServerUriArray`, and a dynamic `ServiceLevel` based on role and runtime health. The primary advertises a higher ServiceLevel than the secondary. See `docs/Redundancy.md` for the full guide.
|
||||
|
||||
## LDAP Authentication
|
||||
|
||||
The server supports LDAP-based user authentication via the `Authentication.Ldap` section in `appsettings.json`. When enabled, credentials are validated by LDAP bind against a GLAuth server (installed at `C:\publish\glauth\`), and LDAP group membership maps to OPC UA permissions: `ReadOnly` (browse/read), `ReadWrite` (read/write tags), `AlarmAck` (alarm acknowledgment). The `IUserAuthenticationProvider` interface is pluggable — `LdapAuthenticationProvider` implements both `IUserAuthenticationProvider` and `IRoleProvider`. See `docs/Security.md` for the full guide and `C:\publish\glauth\auth.md` for LDAP user/group reference.
|
||||
|
||||
## Library Preferences
|
||||
|
||||
- **Logging**: Serilog with rolling daily file sink
|
||||
|
||||
@@ -116,11 +116,62 @@ Controls user authentication and write authorization for the OPC UA server. Defi
|
||||
| `AnonymousCanWrite` | `bool` | `true` | Permits anonymous users to write when `true` |
|
||||
| `Users` | `List<UserCredential>` | `[]` | List of username/password credentials for `UserName` token authentication |
|
||||
|
||||
Each entry in the `Users` list has two properties: `Username` (string) and `Password` (string).
|
||||
Each entry in the `Users` list has two properties: `Username` (string) and `Password` (string). The `Users` list is ignored when `Ldap.Enabled` is `true`.
|
||||
|
||||
The defaults preserve the existing behavior: anonymous clients can connect, read, and write with no credentials required. To restrict writes to authenticated users, set `AnonymousCanWrite` to `false` and add entries to the `Users` list.
|
||||
#### LDAP Authentication
|
||||
|
||||
Example configuration:
|
||||
When `Ldap.Enabled` is `true`, credentials are validated against the configured LDAP server and group membership determines OPC UA permissions. The `Users` list is ignored.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `Ldap.Enabled` | `bool` | `false` | Enables LDAP authentication |
|
||||
| `Ldap.Host` | `string` | `localhost` | LDAP server hostname |
|
||||
| `Ldap.Port` | `int` | `3893` | LDAP server port |
|
||||
| `Ldap.BaseDN` | `string` | `dc=lmxopcua,dc=local` | Base DN for LDAP operations |
|
||||
| `Ldap.BindDnTemplate` | `string` | `cn={username},dc=lmxopcua,dc=local` | Bind DN template (`{username}` is replaced) |
|
||||
| `Ldap.ServiceAccountDn` | `string` | `""` | Service account DN for group lookups |
|
||||
| `Ldap.ServiceAccountPassword` | `string` | `""` | Service account password |
|
||||
| `Ldap.TimeoutSeconds` | `int` | `5` | Connection timeout |
|
||||
| `Ldap.ReadOnlyGroup` | `string` | `ReadOnly` | LDAP group granting read-only access |
|
||||
| `Ldap.ReadWriteGroup` | `string` | `ReadWrite` | LDAP group granting read-write access |
|
||||
| `Ldap.AlarmAckGroup` | `string` | `AlarmAck` | LDAP group granting alarm acknowledgment |
|
||||
|
||||
#### Permission Model
|
||||
|
||||
When LDAP is enabled, authenticated users receive permissions based on their LDAP group membership:
|
||||
|
||||
| LDAP Group | Permission |
|
||||
|---|---|
|
||||
| ReadOnly | Browse and read nodes |
|
||||
| ReadWrite | Browse, read, and write tag values |
|
||||
| AlarmAck | Acknowledge alarms |
|
||||
|
||||
Users can belong to multiple groups. The `admin` user in the default GLAuth configuration belongs to all three groups.
|
||||
|
||||
Example with LDAP authentication:
|
||||
|
||||
```json
|
||||
"Authentication": {
|
||||
"AllowAnonymous": true,
|
||||
"AnonymousCanWrite": false,
|
||||
"Users": [],
|
||||
"Ldap": {
|
||||
"Enabled": true,
|
||||
"Host": "localhost",
|
||||
"Port": 3893,
|
||||
"BaseDN": "dc=lmxopcua,dc=local",
|
||||
"BindDnTemplate": "cn={username},dc=lmxopcua,dc=local",
|
||||
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
|
||||
"ServiceAccountPassword": "serviceaccount123",
|
||||
"TimeoutSeconds": 5,
|
||||
"ReadOnlyGroup": "ReadOnly",
|
||||
"ReadWriteGroup": "ReadWrite",
|
||||
"AlarmAckGroup": "AlarmAck"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example with static user list (no LDAP):
|
||||
|
||||
```json
|
||||
"Authentication": {
|
||||
|
||||
@@ -257,3 +257,41 @@ The CLI tool auto-generates its own client certificate on first use (stored unde
|
||||
**Resolution:**
|
||||
- Regenerate the client certificate using SHA-256 or stronger (recommended).
|
||||
- Alternatively, set `RejectSHA1Certificates` to `false` in the server configuration (not recommended for production).
|
||||
|
||||
---
|
||||
|
||||
## LDAP Authentication
|
||||
|
||||
The server supports LDAP-based user authentication via GLAuth (or any standard LDAP server). When enabled, OPC UA `UserName` token credentials are validated by LDAP bind, and LDAP group membership controls what operations each user can perform.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
OPC UA Client → UserName Token → LmxOpcUa Server → LDAP Bind (validate credentials)
|
||||
→ LDAP Search (resolve group membership)
|
||||
→ Role assignment → Permission enforcement
|
||||
```
|
||||
|
||||
### LDAP Groups and OPC UA Permissions
|
||||
|
||||
| LDAP Group | OPC UA Permission |
|
||||
|---|---|
|
||||
| ReadOnly | Browse and read nodes |
|
||||
| ReadWrite | Read and write tag values |
|
||||
| AlarmAck | Acknowledge alarms |
|
||||
|
||||
Users can belong to multiple groups. A user with all three groups has full access.
|
||||
|
||||
### GLAuth Setup
|
||||
|
||||
The project uses [GLAuth](https://github.com/glauth/glauth) v2.4.0 as the LDAP server, installed at `C:\publish\glauth\`. See `C:\publish\glauth\auth.md` for the complete user/group reference and service management commands.
|
||||
|
||||
### Configuration
|
||||
|
||||
Enable LDAP in `appsettings.json` under `Authentication.Ldap`. See [Configuration Guide](Configuration.md) for the full property reference.
|
||||
|
||||
### Security Considerations
|
||||
|
||||
- LDAP credentials are transmitted in plaintext over the OPC UA channel unless transport security is enabled. Use `Basic256Sha256-SignAndEncrypt` for production deployments.
|
||||
- The GLAuth LDAP server itself listens on plain LDAP (port 3893). Enable LDAPS in `glauth.cfg` for environments where LDAP traffic crosses network boundaries.
|
||||
- The service account password is stored in `appsettings.json`. Protect this file with appropriate filesystem permissions.
|
||||
|
||||
@@ -89,6 +89,34 @@ opcuacli-dotnet.exe redundancy -u opc.tcp://localhost:4841/LmxOpcUa
|
||||
|
||||
Both instances report the same `ServerUriArray` and expose the same Galaxy namespace (`urn:ZB:LmxOpcUa`).
|
||||
|
||||
## LDAP Authentication Update
|
||||
|
||||
Updated: `2026-03-28`
|
||||
|
||||
Both instances updated to use LDAP authentication via GLAuth.
|
||||
|
||||
Configuration changes (both instances):
|
||||
- `Authentication.AllowAnonymous`: `true` (anonymous can browse/read)
|
||||
- `Authentication.AnonymousCanWrite`: `false` (anonymous writes blocked)
|
||||
- `Authentication.Ldap.Enabled`: `true`
|
||||
- `Authentication.Ldap.Host`: `localhost`
|
||||
- `Authentication.Ldap.Port`: `3893`
|
||||
- `Authentication.Ldap.BaseDN`: `dc=lmxopcua,dc=local`
|
||||
|
||||
LDAP server: GLAuth v2.4.0 at `C:\publish\glauth\` (Windows service: `GLAuth`)
|
||||
|
||||
Permission verification (instance1, port 4840):
|
||||
```
|
||||
anonymous read → allowed
|
||||
anonymous write → denied (BadUserAccessDenied)
|
||||
readonly read → allowed
|
||||
readonly write → denied (BadUserAccessDenied)
|
||||
readwrite write → allowed
|
||||
admin write → allowed
|
||||
alarmack write → denied (BadUserAccessDenied)
|
||||
bad password → denied (connection rejected)
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
The service deployment and restart succeeded. The live CLI checks confirm the endpoint is reachable and that the array node identifier has changed to the bracketless form. The array value on the live service still prints as blank even though the status is good, so if this environment should have populated `MoveInPartNumbers`, the runtime data path still needs follow-up investigation.
|
||||
|
||||
@@ -20,8 +20,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of username/password pairs accepted for authenticated access.
|
||||
/// Ignored when Ldap.Enabled is true.
|
||||
/// </summary>
|
||||
public List<UserCredential> Users { get; set; } = new List<UserCredential>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the LDAP authentication settings. When Ldap.Enabled is true,
|
||||
/// credentials are validated against the LDAP server and the Users list is ignored.
|
||||
/// </summary>
|
||||
public LdapConfiguration Ldap { get; set; } = new LdapConfiguration();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -104,6 +104,32 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
||||
Log.Warning("Only the 'None' security profile is configured — transport security is disabled");
|
||||
}
|
||||
|
||||
// Authentication
|
||||
Log.Information("Authentication.AllowAnonymous={AllowAnonymous}, AnonymousCanWrite={AnonymousCanWrite}",
|
||||
config.Authentication.AllowAnonymous, config.Authentication.AnonymousCanWrite);
|
||||
|
||||
if (config.Authentication.Ldap.Enabled)
|
||||
{
|
||||
Log.Information("Authentication.Ldap.Enabled=true, Host={Host}, Port={Port}, BaseDN={BaseDN}",
|
||||
config.Authentication.Ldap.Host, config.Authentication.Ldap.Port, config.Authentication.Ldap.BaseDN);
|
||||
Log.Information("Authentication.Ldap groups: ReadOnly={ReadOnly}, ReadWrite={ReadWrite}, AlarmAck={AlarmAck}",
|
||||
config.Authentication.Ldap.ReadOnlyGroup, config.Authentication.Ldap.ReadWriteGroup, config.Authentication.Ldap.AlarmAckGroup);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.Authentication.Ldap.ServiceAccountDn))
|
||||
{
|
||||
Log.Warning("Authentication.Ldap.ServiceAccountDn is empty — group lookups will fail");
|
||||
}
|
||||
|
||||
if (config.Authentication.Users.Count > 0)
|
||||
{
|
||||
Log.Warning("Authentication.Users list is ignored when Ldap is enabled");
|
||||
}
|
||||
}
|
||||
else if (config.Authentication.Users.Count > 0)
|
||||
{
|
||||
Log.Information("Authentication.Users configured: {Count} user(s)", config.Authentication.Users.Count);
|
||||
}
|
||||
|
||||
// Redundancy
|
||||
if (config.OpcUa.ApplicationUri != null)
|
||||
Log.Information("OpcUa.ApplicationUri={ApplicationUri}", config.OpcUa.ApplicationUri);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// LDAP authentication and group-to-role mapping settings.
|
||||
/// </summary>
|
||||
public class LdapConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets whether LDAP authentication is enabled.
|
||||
/// When true, user credentials are validated against the configured LDAP server
|
||||
/// and group membership determines OPC UA permissions.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the LDAP server hostname or IP address.
|
||||
/// </summary>
|
||||
public string Host { get; set; } = "localhost";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the LDAP server port.
|
||||
/// </summary>
|
||||
public int Port { get; set; } = 3893;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the base DN for LDAP operations.
|
||||
/// </summary>
|
||||
public string BaseDN { get; set; } = "dc=lmxopcua,dc=local";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the bind DN template. Use {username} as a placeholder.
|
||||
/// </summary>
|
||||
public string BindDnTemplate { get; set; } = "cn={username},dc=lmxopcua,dc=local";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the service account DN used for LDAP searches (group lookups).
|
||||
/// </summary>
|
||||
public string ServiceAccountDn { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the service account password.
|
||||
/// </summary>
|
||||
public string ServiceAccountPassword { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the LDAP connection timeout in seconds.
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the LDAP group name that grants read-only access.
|
||||
/// </summary>
|
||||
public string ReadOnlyGroup { get; set; } = "ReadOnly";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the LDAP group name that grants read-write access.
|
||||
/// </summary>
|
||||
public string ReadWriteGroup { get; set; } = "ReadWrite";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the LDAP group name that grants alarm acknowledgment access.
|
||||
/// </summary>
|
||||
public string AlarmAckGroup { get; set; } = "AlarmAck";
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
@@ -10,4 +12,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
||||
/// </summary>
|
||||
bool ValidateCredentials(string username, string password);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extended interface for providers that can resolve application-level roles for authenticated users.
|
||||
/// When the auth provider implements this interface, OnImpersonateUser uses the returned roles
|
||||
/// to control write and alarm-ack permissions.
|
||||
/// </summary>
|
||||
public interface IRoleProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the set of application-level roles granted to the user.
|
||||
/// Known roles: "ReadOnly", "ReadWrite", "AlarmAck".
|
||||
/// </summary>
|
||||
IReadOnlyList<string> GetUserRoles(string username);
|
||||
}
|
||||
}
|
||||
|
||||
149
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/LdapAuthenticationProvider.cs
Normal file
149
src/ZB.MOM.WW.LmxOpcUa.Host/Domain/LdapAuthenticationProvider.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.DirectoryServices.Protocols;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates credentials via LDAP bind and resolves group membership to application roles.
|
||||
/// </summary>
|
||||
public class LdapAuthenticationProvider : IUserAuthenticationProvider, IRoleProvider
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<LdapAuthenticationProvider>();
|
||||
|
||||
private readonly LdapConfiguration _config;
|
||||
private readonly Dictionary<string, string> _groupToRole;
|
||||
|
||||
public LdapAuthenticationProvider(LdapConfiguration config)
|
||||
{
|
||||
_config = config;
|
||||
_groupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
{ config.ReadOnlyGroup, "ReadOnly" },
|
||||
{ config.ReadWriteGroup, "ReadWrite" },
|
||||
{ config.AlarmAckGroup, "AlarmAck" }
|
||||
};
|
||||
}
|
||||
|
||||
public bool ValidateCredentials(string username, string password)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bindDn = _config.BindDnTemplate.Replace("{username}", username);
|
||||
using (var connection = CreateConnection())
|
||||
{
|
||||
connection.Bind(new NetworkCredential(bindDn, password));
|
||||
}
|
||||
Log.Debug("LDAP bind succeeded for {Username}", username);
|
||||
return true;
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
Log.Debug("LDAP bind failed for {Username}: {Error}", username, ex.Message);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "LDAP error during credential validation for {Username}", username);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> GetUserRoles(string username)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var connection = CreateConnection())
|
||||
{
|
||||
// Bind with service account to search
|
||||
connection.Bind(new NetworkCredential(_config.ServiceAccountDn, _config.ServiceAccountPassword));
|
||||
|
||||
var request = new SearchRequest(
|
||||
_config.BaseDN,
|
||||
$"(cn={EscapeLdapFilter(username)})",
|
||||
SearchScope.Subtree,
|
||||
"memberOf");
|
||||
|
||||
var response = (SearchResponse)connection.SendRequest(request);
|
||||
|
||||
if (response.Entries.Count == 0)
|
||||
{
|
||||
Log.Warning("LDAP search returned no entries for {Username}", username);
|
||||
return new[] { "ReadOnly" }; // safe fallback
|
||||
}
|
||||
|
||||
var entry = response.Entries[0];
|
||||
var memberOf = entry.Attributes["memberOf"];
|
||||
if (memberOf == null || memberOf.Count == 0)
|
||||
{
|
||||
Log.Debug("No memberOf attributes for {Username}, defaulting to ReadOnly", username);
|
||||
return new[] { "ReadOnly" };
|
||||
}
|
||||
|
||||
var roles = new List<string>();
|
||||
for (int i = 0; i < memberOf.Count; i++)
|
||||
{
|
||||
var dn = memberOf[i]?.ToString() ?? "";
|
||||
// Extract the OU/CN from the memberOf DN (e.g., "ou=ReadWrite,ou=groups,dc=...")
|
||||
var groupName = ExtractGroupName(dn);
|
||||
if (groupName != null && _groupToRole.TryGetValue(groupName, out var role))
|
||||
{
|
||||
roles.Add(role);
|
||||
}
|
||||
}
|
||||
|
||||
if (roles.Count == 0)
|
||||
{
|
||||
Log.Debug("No matching role groups for {Username}, defaulting to ReadOnly", username);
|
||||
roles.Add("ReadOnly");
|
||||
}
|
||||
|
||||
Log.Debug("LDAP roles for {Username}: [{Roles}]", username, string.Join(", ", roles));
|
||||
return roles;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to resolve LDAP roles for {Username}, defaulting to ReadOnly", username);
|
||||
return new[] { "ReadOnly" };
|
||||
}
|
||||
}
|
||||
|
||||
private LdapConnection CreateConnection()
|
||||
{
|
||||
var identifier = new LdapDirectoryIdentifier(_config.Host, _config.Port);
|
||||
var connection = new LdapConnection(identifier)
|
||||
{
|
||||
AuthType = AuthType.Basic,
|
||||
Timeout = TimeSpan.FromSeconds(_config.TimeoutSeconds)
|
||||
};
|
||||
connection.SessionOptions.ProtocolVersion = 3;
|
||||
return connection;
|
||||
}
|
||||
|
||||
private static string? ExtractGroupName(string dn)
|
||||
{
|
||||
// Parse "ou=ReadWrite,ou=groups,dc=..." or "cn=ReadWrite,..."
|
||||
if (string.IsNullOrEmpty(dn)) return null;
|
||||
var parts = dn.Split(',');
|
||||
if (parts.Length == 0) return null;
|
||||
var first = parts[0].Trim();
|
||||
var eqIdx = first.IndexOf('=');
|
||||
return eqIdx >= 0 ? first.Substring(eqIdx + 1) : null;
|
||||
}
|
||||
|
||||
private static string EscapeLdapFilter(string input)
|
||||
{
|
||||
return input
|
||||
.Replace("\\", "\\5c")
|
||||
.Replace("*", "\\2a")
|
||||
.Replace("(", "\\28")
|
||||
.Replace(")", "\\29")
|
||||
.Replace("\0", "\\00");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
private readonly HistorianDataSource? _historianDataSource;
|
||||
private readonly bool _alarmTrackingEnabled;
|
||||
private readonly bool _anonymousCanWrite;
|
||||
private readonly Func<string?, IReadOnlyList<string>?>? _appRoleLookup;
|
||||
private readonly bool _ldapRolesEnabled;
|
||||
private readonly string _namespaceUri;
|
||||
|
||||
// NodeId → full_tag_reference for read/write resolution
|
||||
@@ -192,7 +194,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
PerformanceMetrics metrics,
|
||||
HistorianDataSource? historianDataSource = null,
|
||||
bool alarmTrackingEnabled = false,
|
||||
bool anonymousCanWrite = true)
|
||||
bool anonymousCanWrite = true,
|
||||
Func<string?, IReadOnlyList<string>?>? appRoleLookup = null,
|
||||
bool ldapRolesEnabled = false)
|
||||
: base(server, configuration, namespaceUri)
|
||||
{
|
||||
_namespaceUri = namespaceUri;
|
||||
@@ -201,6 +205,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
_historianDataSource = historianDataSource;
|
||||
_alarmTrackingEnabled = alarmTrackingEnabled;
|
||||
_anonymousCanWrite = anonymousCanWrite;
|
||||
_appRoleLookup = appRoleLookup;
|
||||
_ldapRolesEnabled = ldapRolesEnabled;
|
||||
|
||||
// Wire up data change delivery
|
||||
_mxAccessClient.OnTagValueChanged += OnMxAccessDataChange;
|
||||
@@ -467,6 +473,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
private ServiceResult OnAlarmAcknowledge(
|
||||
ISystemContext context, ConditionState condition, byte[] eventId, LocalizedText comment)
|
||||
{
|
||||
if (!HasAlarmAckPermission(context))
|
||||
return new ServiceResult(StatusCodes.BadUserAccessDenied);
|
||||
|
||||
var alarmInfo = _alarmInAlarmTags.Values
|
||||
.FirstOrDefault(a => a.ConditionNode == condition);
|
||||
if (alarmInfo == null)
|
||||
@@ -1103,9 +1112,8 @@ 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))
|
||||
// Enforce role-based write access
|
||||
if (!HasWritePermission(context))
|
||||
{
|
||||
errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied);
|
||||
continue;
|
||||
@@ -1155,6 +1163,39 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasWritePermission(OperationContext context)
|
||||
{
|
||||
// When LDAP roles are active, check for ReadWrite role
|
||||
if (_ldapRolesEnabled && _appRoleLookup != null)
|
||||
{
|
||||
var username = context.UserIdentity?.GetIdentityToken() is UserNameIdentityToken token ? token.UserName : null;
|
||||
var roles = _appRoleLookup(username);
|
||||
if (roles == null) return false; // unknown user
|
||||
return roles.Contains("ReadWrite");
|
||||
}
|
||||
|
||||
// Legacy behavior: reject anonymous writes when AnonymousCanWrite is false
|
||||
if (!_anonymousCanWrite && context.UserIdentity?.GrantedRoleIds != null &&
|
||||
!context.UserIdentity.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_AuthenticatedUser))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool HasAlarmAckPermission(ISystemContext context)
|
||||
{
|
||||
if (!_ldapRolesEnabled || _appRoleLookup == null)
|
||||
return true; // no LDAP restrictions without LDAP roles
|
||||
|
||||
var opContext = context as SystemContext;
|
||||
var username = opContext?.UserIdentity?.GetIdentityToken() is UserNameIdentityToken token ? token.UserName : null;
|
||||
var roles = _appRoleLookup(username);
|
||||
if (roles == null) return false;
|
||||
return roles.Contains("AlarmAck");
|
||||
}
|
||||
|
||||
private bool TryApplyArrayElementWrite(string tagRef, object? writeValue, string indexRange, out object updatedArray)
|
||||
{
|
||||
updatedArray = null!;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using Opc.Ua;
|
||||
@@ -29,6 +30,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
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>
|
||||
@@ -36,6 +38,22 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
/// </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>
|
||||
@@ -69,7 +87,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
{
|
||||
var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa";
|
||||
_nodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics,
|
||||
_historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite);
|
||||
_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());
|
||||
@@ -206,10 +226,23 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
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),
|
||||
new List<Role> { Role.AuthenticatedUser });
|
||||
Log.Information("User {Username} authenticated", userNameToken.UserName);
|
||||
new UserIdentity(userNameToken), roles);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
var policies = new UserTokenPolicyCollection();
|
||||
if (_authConfig.AllowAnonymous)
|
||||
policies.Add(new UserTokenPolicy(UserTokenType.Anonymous));
|
||||
if (_authConfig.Users.Count > 0)
|
||||
if (_authConfig.Users.Count > 0 || _authConfig.Ldap.Enabled)
|
||||
policies.Add(new UserTokenPolicy(UserTokenType.UserName));
|
||||
|
||||
if (policies.Count == 0)
|
||||
|
||||
@@ -161,9 +161,21 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
|
||||
var historianDataSource = _config.Historian.Enabled
|
||||
? new Historian.HistorianDataSource(_config.Historian)
|
||||
: null;
|
||||
var authProvider = _config.Authentication.Users.Count > 0
|
||||
? new Domain.ConfigUserAuthenticationProvider(_config.Authentication.Users)
|
||||
: (Domain.IUserAuthenticationProvider?)null;
|
||||
Domain.IUserAuthenticationProvider? authProvider;
|
||||
if (_config.Authentication.Ldap.Enabled)
|
||||
{
|
||||
authProvider = new Domain.LdapAuthenticationProvider(_config.Authentication.Ldap);
|
||||
Log.Information("LDAP authentication enabled (server={Host}:{Port}, baseDN={BaseDN})",
|
||||
_config.Authentication.Ldap.Host, _config.Authentication.Ldap.Port, _config.Authentication.Ldap.BaseDN);
|
||||
}
|
||||
else if (_config.Authentication.Users.Count > 0)
|
||||
{
|
||||
authProvider = new Domain.ConfigUserAuthenticationProvider(_config.Authentication.Users);
|
||||
}
|
||||
else
|
||||
{
|
||||
authProvider = null;
|
||||
}
|
||||
_serverHost = new OpcUaServerHost(_config.OpcUa, effectiveMxClient, _metrics, historianDataSource,
|
||||
_config.Authentication, authProvider, _config.Security, _config.Redundancy);
|
||||
|
||||
|
||||
@@ -34,6 +34,11 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- LDAP authentication -->
|
||||
<Reference Include="System.DirectoryServices.Protocols" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- MXAccess COM interop -->
|
||||
<Reference Include="ArchestrA.MxAccess">
|
||||
|
||||
@@ -35,8 +35,21 @@
|
||||
},
|
||||
"Authentication": {
|
||||
"AllowAnonymous": true,
|
||||
"AnonymousCanWrite": true,
|
||||
"Users": []
|
||||
"AnonymousCanWrite": false,
|
||||
"Users": [],
|
||||
"Ldap": {
|
||||
"Enabled": false,
|
||||
"Host": "localhost",
|
||||
"Port": 3893,
|
||||
"BaseDN": "dc=lmxopcua,dc=local",
|
||||
"BindDnTemplate": "cn={username},dc=lmxopcua,dc=local",
|
||||
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
|
||||
"ServiceAccountPassword": "serviceaccount123",
|
||||
"TimeoutSeconds": 5,
|
||||
"ReadOnlyGroup": "ReadOnly",
|
||||
"ReadWriteGroup": "ReadWrite",
|
||||
"AlarmAckGroup": "AlarmAck"
|
||||
}
|
||||
},
|
||||
"Security": {
|
||||
"Profiles": ["None"],
|
||||
|
||||
@@ -70,5 +70,224 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Authentication
|
||||
config.AnonymousCanWrite.ShouldBeTrue();
|
||||
config.Users.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthenticationConfiguration_LdapDefaults()
|
||||
{
|
||||
var config = new AuthenticationConfiguration();
|
||||
|
||||
config.Ldap.ShouldNotBeNull();
|
||||
config.Ldap.Enabled.ShouldBeFalse();
|
||||
config.Ldap.Host.ShouldBe("localhost");
|
||||
config.Ldap.Port.ShouldBe(3893);
|
||||
config.Ldap.BaseDN.ShouldBe("dc=lmxopcua,dc=local");
|
||||
config.Ldap.ReadOnlyGroup.ShouldBe("ReadOnly");
|
||||
config.Ldap.ReadWriteGroup.ShouldBe("ReadWrite");
|
||||
config.Ldap.AlarmAckGroup.ShouldBe("AlarmAck");
|
||||
config.Ldap.TimeoutSeconds.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapConfiguration_BindDnTemplate_Default()
|
||||
{
|
||||
var config = new LdapConfiguration();
|
||||
config.BindDnTemplate.ShouldBe("cn={username},dc=lmxopcua,dc=local");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapAuthenticationProvider_ValidBind_ReturnsTrue()
|
||||
{
|
||||
// This test requires GLAuth running on localhost:3893
|
||||
// Skip if not available
|
||||
var ldapConfig = CreateGlAuthConfig();
|
||||
var provider = new LdapAuthenticationProvider(ldapConfig);
|
||||
|
||||
try
|
||||
{
|
||||
provider.ValidateCredentials("readonly", "readonly123").ShouldBeTrue();
|
||||
}
|
||||
catch (System.Exception)
|
||||
{
|
||||
// GLAuth not running - skip gracefully
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapAuthenticationProvider_InvalidPassword_ReturnsFalse()
|
||||
{
|
||||
var ldapConfig = CreateGlAuthConfig();
|
||||
var provider = new LdapAuthenticationProvider(ldapConfig);
|
||||
|
||||
try
|
||||
{
|
||||
provider.ValidateCredentials("readonly", "wrongpassword").ShouldBeFalse();
|
||||
}
|
||||
catch (System.Exception)
|
||||
{
|
||||
return; // GLAuth not running
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapAuthenticationProvider_UnknownUser_ReturnsFalse()
|
||||
{
|
||||
var ldapConfig = CreateGlAuthConfig();
|
||||
var provider = new LdapAuthenticationProvider(ldapConfig);
|
||||
|
||||
try
|
||||
{
|
||||
provider.ValidateCredentials("nonexistent", "anything").ShouldBeFalse();
|
||||
}
|
||||
catch (System.Exception)
|
||||
{
|
||||
return; // GLAuth not running
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapAuthenticationProvider_ReadOnlyUser_HasReadOnlyRole()
|
||||
{
|
||||
var ldapConfig = CreateGlAuthConfig();
|
||||
var provider = new LdapAuthenticationProvider(ldapConfig);
|
||||
|
||||
try
|
||||
{
|
||||
provider.ValidateCredentials("readonly", "readonly123").ShouldBeTrue();
|
||||
var roles = provider.GetUserRoles("readonly");
|
||||
roles.ShouldContain("ReadOnly");
|
||||
roles.ShouldNotContain("ReadWrite");
|
||||
roles.ShouldNotContain("AlarmAck");
|
||||
}
|
||||
catch (System.Exception)
|
||||
{
|
||||
return; // GLAuth not running
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapAuthenticationProvider_ReadWriteUser_HasReadWriteRole()
|
||||
{
|
||||
var ldapConfig = CreateGlAuthConfig();
|
||||
var provider = new LdapAuthenticationProvider(ldapConfig);
|
||||
|
||||
try
|
||||
{
|
||||
provider.ValidateCredentials("readwrite", "readwrite123").ShouldBeTrue();
|
||||
var roles = provider.GetUserRoles("readwrite");
|
||||
roles.ShouldContain("ReadWrite");
|
||||
roles.ShouldNotContain("AlarmAck");
|
||||
}
|
||||
catch (System.Exception)
|
||||
{
|
||||
return; // GLAuth not running
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapAuthenticationProvider_AlarmAckUser_HasAlarmAckRole()
|
||||
{
|
||||
var ldapConfig = CreateGlAuthConfig();
|
||||
var provider = new LdapAuthenticationProvider(ldapConfig);
|
||||
|
||||
try
|
||||
{
|
||||
provider.ValidateCredentials("alarmack", "alarmack123").ShouldBeTrue();
|
||||
var roles = provider.GetUserRoles("alarmack");
|
||||
roles.ShouldContain("AlarmAck");
|
||||
roles.ShouldNotContain("ReadWrite");
|
||||
}
|
||||
catch (System.Exception)
|
||||
{
|
||||
return; // GLAuth not running
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapAuthenticationProvider_AdminUser_HasAllRoles()
|
||||
{
|
||||
var ldapConfig = CreateGlAuthConfig();
|
||||
var provider = new LdapAuthenticationProvider(ldapConfig);
|
||||
|
||||
try
|
||||
{
|
||||
provider.ValidateCredentials("admin", "admin123").ShouldBeTrue();
|
||||
var roles = provider.GetUserRoles("admin");
|
||||
roles.ShouldContain("ReadOnly");
|
||||
roles.ShouldContain("ReadWrite");
|
||||
roles.ShouldContain("AlarmAck");
|
||||
}
|
||||
catch (System.Exception)
|
||||
{
|
||||
return; // GLAuth not running
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapAuthenticationProvider_ImplementsIRoleProvider()
|
||||
{
|
||||
var ldapConfig = CreateGlAuthConfig();
|
||||
var provider = new LdapAuthenticationProvider(ldapConfig);
|
||||
|
||||
(provider is IRoleProvider).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigUserAuthenticationProvider_DoesNotImplementIRoleProvider()
|
||||
{
|
||||
var provider = new ConfigUserAuthenticationProvider(new List<UserCredential>());
|
||||
(provider is IRoleProvider).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapAuthenticationProvider_ConnectionFailure_ReturnsFalse()
|
||||
{
|
||||
var ldapConfig = new LdapConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
Host = "localhost",
|
||||
Port = 19999, // no server here
|
||||
TimeoutSeconds = 1
|
||||
};
|
||||
var provider = new LdapAuthenticationProvider(ldapConfig);
|
||||
|
||||
provider.ValidateCredentials("anyone", "anything").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapAuthenticationProvider_ConnectionFailure_GetUserRoles_FallsBackToReadOnly()
|
||||
{
|
||||
var ldapConfig = new LdapConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
Host = "localhost",
|
||||
Port = 19999, // no server here
|
||||
TimeoutSeconds = 1,
|
||||
ServiceAccountDn = "cn=svc,dc=test",
|
||||
ServiceAccountPassword = "test"
|
||||
};
|
||||
var provider = new LdapAuthenticationProvider(ldapConfig);
|
||||
|
||||
var roles = provider.GetUserRoles("anyone");
|
||||
roles.ShouldContain("ReadOnly");
|
||||
}
|
||||
|
||||
private static LdapConfiguration CreateGlAuthConfig()
|
||||
{
|
||||
return new LdapConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
Host = "localhost",
|
||||
Port = 3893,
|
||||
BaseDN = "dc=lmxopcua,dc=local",
|
||||
BindDnTemplate = "cn={username},dc=lmxopcua,dc=local",
|
||||
ServiceAccountDn = "cn=serviceaccount,dc=lmxopcua,dc=local",
|
||||
ServiceAccountPassword = "serviceaccount123",
|
||||
TimeoutSeconds = 5,
|
||||
ReadOnlyGroup = "ReadOnly",
|
||||
ReadWriteGroup = "ReadWrite",
|
||||
AlarmAckGroup = "AlarmAck"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user