Consolidate LDAP roles into OPC UA session roles with granular write permissions

Map LDAP groups to custom OPC UA role NodeIds on RoleBasedIdentity.GrantedRoleIds
during authentication, replacing the username-to-role side cache. Split ReadWrite
into WriteOperate/WriteTune/WriteConfigure so write access is gated per Galaxy
security classification. AnonymousCanWrite now behaves consistently regardless
of LDAP state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-29 01:50:16 -04:00
parent 50b9603465
commit 50b85d41bd
21 changed files with 549 additions and 94 deletions

View File

@@ -87,7 +87,7 @@ The server supports non-transparent warm/hot redundancy via the `Redundancy` sec
## LDAP Authentication ## LDAP Authentication
The server uses 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). `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. The server uses 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), `WriteOperate` (write FreeAccess/Operate attributes), `WriteTune` (write Tune attributes), `WriteConfigure` (write Configure attributes), `AlarmAck` (alarm acknowledgment). `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 ## Library Preferences

View File

@@ -120,7 +120,7 @@ Key behaviors:
- **Message** -- Uses `CachedMessage` (from DescAttrName) when available on activation. Falls back to a generated `"Alarm active: {SourceName}"` string. Cleared alarms always use `"Alarm cleared: {SourceName}"`. - **Message** -- Uses `CachedMessage` (from DescAttrName) when available on activation. Falls back to a generated `"Alarm active: {SourceName}"` string. Cleared alarms always use `"Alarm cleared: {SourceName}"`.
- **Severity** -- Set from `CachedSeverity`, which was read from the Priority tag. - **Severity** -- Set from `CachedSeverity`, which was read from the Priority tag.
- **Retain** -- `true` while the alarm is active or unacknowledged. This keeps the condition visible in condition refresh responses. - **Retain** -- `true` while the alarm is active or unacknowledged. This keeps the condition visible in condition refresh responses.
- **Acknowledged state** -- Reset to `false` when the alarm activates, requiring explicit client acknowledgment. - **Acknowledged state** -- Reset to `false` when the alarm activates, requiring explicit client acknowledgment. When role-based auth is active, alarm acknowledgment requires the `AlarmAck` role on the session (checked via `GrantedRoleIds`). Users without this role receive `BadUserAccessDenied`.
The event is reported by walking up the notifier chain from the source variable's parent through all ancestor nodes. Each ancestor with `EventNotifier` set receives the event via `ReportEvent`, so clients subscribed at any level in the Galaxy hierarchy see alarm transitions from descendant objects. The event is reported by walking up the notifier chain from the source variable's parent through all ancestor nodes. Each ancestor with `EventNotifier` set receives the event via `ReportEvent`, so clients subscribed at any level in the Galaxy hierarchy see alarm transitions from descendant objects.

View File

@@ -130,21 +130,27 @@ When `Ldap.Enabled` is `true`, credentials are validated against the configured
| `Ldap.ServiceAccountPassword` | `string` | `""` | Service account password | | `Ldap.ServiceAccountPassword` | `string` | `""` | Service account password |
| `Ldap.TimeoutSeconds` | `int` | `5` | Connection timeout | | `Ldap.TimeoutSeconds` | `int` | `5` | Connection timeout |
| `Ldap.ReadOnlyGroup` | `string` | `ReadOnly` | LDAP group granting read-only access | | `Ldap.ReadOnlyGroup` | `string` | `ReadOnly` | LDAP group granting read-only access |
| `Ldap.ReadWriteGroup` | `string` | `ReadWrite` | LDAP group granting read-write access | | `Ldap.WriteOperateGroup` | `string` | `WriteOperate` | LDAP group granting write access for FreeAccess/Operate attributes |
| `Ldap.WriteTuneGroup` | `string` | `WriteTune` | LDAP group granting write access for Tune attributes |
| `Ldap.WriteConfigureGroup` | `string` | `WriteConfigure` | LDAP group granting write access for Configure attributes |
| `Ldap.AlarmAckGroup` | `string` | `AlarmAck` | LDAP group granting alarm acknowledgment | | `Ldap.AlarmAckGroup` | `string` | `AlarmAck` | LDAP group granting alarm acknowledgment |
#### Permission Model #### Permission Model
When LDAP is enabled, authenticated users receive permissions based on their LDAP group membership: When LDAP is enabled, LDAP group membership is mapped to OPC UA session role NodeIds during authentication. All authenticated LDAP users can browse and read nodes regardless of group membership. Groups grant additional permissions:
| LDAP Group | Permission | | LDAP Group | Permission |
|---|---| |---|---|
| ReadOnly | Browse and read nodes | | ReadOnly | No additional permissions (read-only access) |
| ReadWrite | Browse, read, and write tag values | | WriteOperate | Write FreeAccess and Operate attributes |
| WriteTune | Write Tune attributes |
| WriteConfigure | Write Configure attributes |
| AlarmAck | Acknowledge alarms | | AlarmAck | Acknowledge alarms |
Users can belong to multiple groups. The `admin` user in the default GLAuth configuration belongs to all three groups. Users can belong to multiple groups. The `admin` user in the default GLAuth configuration belongs to all three groups.
Write access depends on both the user's role and the Galaxy attribute's security classification. See the [Effective Permission Matrix](Security.md#effective-permission-matrix) in the Security Guide for the full breakdown.
Example configuration: Example configuration:
```json ```json
@@ -161,7 +167,9 @@ Example configuration:
"ServiceAccountPassword": "serviceaccount123", "ServiceAccountPassword": "serviceaccount123",
"TimeoutSeconds": 5, "TimeoutSeconds": 5,
"ReadOnlyGroup": "ReadOnly", "ReadOnlyGroup": "ReadOnly",
"ReadWriteGroup": "ReadWrite", "WriteOperateGroup": "WriteOperate",
"WriteTuneGroup": "WriteTune",
"WriteConfigureGroup": "WriteConfigure",
"AlarmAckGroup": "AlarmAck" "AlarmAckGroup": "AlarmAck"
} }
} }

View File

@@ -63,15 +63,17 @@ The `ServiceLevel` is updated whenever MXAccess connection state changes or Gala
`UserTokenPolicies` are dynamically configured based on the `Authentication` settings in `appsettings.json`: `UserTokenPolicies` are dynamically configured based on the `Authentication` settings in `appsettings.json`:
- An `Anonymous` user token policy is added when `AllowAnonymous` is `true` (the default). - An `Anonymous` user token policy is added when `AllowAnonymous` is `true` (the default).
- A `UserName` user token policy is added when `Ldap.Enabled` is `true`. - A `UserName` user token policy is added when an authentication provider is configured (LDAP or injected).
Both policies can be active simultaneously, allowing clients to connect with or without credentials. Both policies can be active simultaneously, allowing clients to connect with or without credentials.
### Session impersonation ### Session impersonation
When a client presents `UserName` credentials, the server validates them through `IUserAuthenticationProvider`. If LDAP authentication is enabled, credentials are validated via LDAP bind and group membership determines the user's application-level roles (`ReadOnly`, `ReadWrite`, `AlarmAck`). If validation fails, the session is rejected. When a client presents `UserName` credentials, the server validates them through `IUserAuthenticationProvider`. If the provider also implements `IRoleProvider` (as `LdapAuthenticationProvider` does), LDAP group membership is resolved once during authentication and mapped to custom OPC UA role `NodeId`s in a dedicated `urn:zbmom:lmxopcua:roles` namespace. These role NodeIds are added to the session's `RoleBasedIdentity.GrantedRoleIds`.
On successful validation, the session identity is set to a `RoleBasedIdentity` that carries the user's granted role IDs. Authenticated users receive the `WellKnownRole_AuthenticatedUser` role. Anonymous connections receive the `WellKnownRole_Anonymous` role. When LDAP is enabled, application-level roles from group membership control write and alarm-ack permissions. Without LDAP, `AnonymousCanWrite` controls whether anonymous users can write. Anonymous sessions receive `WellKnownRole_Anonymous`. Authenticated sessions receive `WellKnownRole_AuthenticatedUser` plus any LDAP-derived role NodeIds. Permission checks in `LmxNodeManager` inspect `GrantedRoleIds` directly — no username extraction or side-channel cache is needed.
`AnonymousCanWrite` controls whether anonymous sessions can write, regardless of whether LDAP is enabled.
## Certificate handling ## Certificate handling

View File

@@ -266,25 +266,50 @@ The CLI tool auto-generates its own client certificate on first use (stored unde
## LDAP Authentication ## 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. 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. LDAP group membership is resolved once during authentication and mapped to custom OPC UA role `NodeId`s in the `urn:zbmom:lmxopcua:roles` namespace. These role NodeIds are stored on the session's `RoleBasedIdentity.GrantedRoleIds` and checked directly during write and alarm-ack operations.
### Architecture ### Architecture
``` ```
OPC UA Client → UserName Token → LmxOpcUa Server → LDAP Bind (validate credentials) OPC UA Client → UserName Token → LmxOpcUa Server → LDAP Bind (validate credentials)
→ LDAP Search (resolve group membership) → LDAP Search (resolve group membership)
Role assignment → Permission enforcement Map groups to OPC UA role NodeIds
→ Store on RoleBasedIdentity.GrantedRoleIds
→ Permission checks via GrantedRoleIds.Contains()
``` ```
### LDAP Groups and OPC UA Permissions ### LDAP Groups and OPC UA Permissions
| LDAP Group | OPC UA Permission | All authenticated LDAP users can browse and read nodes regardless of group membership. Groups grant additional permissions:
| LDAP Group | Permission |
|---|---| |---|---|
| ReadOnly | Browse and read nodes | | ReadOnly | No additional permissions (read-only access) |
| ReadWrite | Read and write tag values | | WriteOperate | Write FreeAccess and Operate attributes |
| WriteTune | Write Tune attributes |
| WriteConfigure | Write Configure attributes |
| AlarmAck | Acknowledge alarms | | AlarmAck | Acknowledge alarms |
Users can belong to multiple groups. A user with all three groups has full access. Users can belong to multiple groups. The `admin` user in the default GLAuth configuration belongs to all groups.
### Effective Permission Matrix
The effective permission for a write operation depends on two factors: the user's session role (from LDAP group membership or anonymous access) and the Galaxy attribute's security classification. The security classification controls the node's `AccessLevel` — attributes classified as `SecuredWrite`, `VerifiedWrite`, or `ViewOnly` are exposed as read-only nodes regardless of the user's role. For writable classifications, the required write role depends on the classification.
| | FreeAccess | Operate | SecuredWrite | VerifiedWrite | Tune | Configure | ViewOnly |
|---|---|---|---|---|---|---|---|
| **Anonymous (`AnonymousCanWrite=true`)** | Write | Write | Read | Read | Write | Write | Read |
| **Anonymous (`AnonymousCanWrite=false`)** | Read | Read | Read | Read | Read | Read | Read |
| **ReadOnly** | Read | Read | Read | Read | Read | Read | Read |
| **WriteOperate** | Write | Write | Read | Read | Read | Read | Read |
| **WriteTune** | Read | Read | Read | Read | Write | Read | Read |
| **WriteConfigure** | Read | Read | Read | Read | Read | Write | Read |
| **AlarmAck** (only) | Read | Read | Read | Read | Read | Read | Read |
| **Admin** (all groups) | Write | Write | Read | Read | Write | Write | Read |
All roles can browse and read all nodes. The "Read" entries above mean the node is either read-only by classification or the user lacks the required write role. "Write" means the write is permitted by both the node's classification and the user's role.
Alarm acknowledgment is an independent permission controlled by the `AlarmAck` role and is not affected by security classification.
### GLAuth Setup ### GLAuth Setup

View File

@@ -143,6 +143,62 @@ alarms --node DEV --refresh:
Same 5 alarms visible at DEV (grandparent) level Same 5 alarms visible at DEV (grandparent) level
``` ```
## Auth Consolidation Update
Updated: `2026-03-28`
Both instances updated to consolidate LDAP roles into OPC UA session roles (`RoleBasedIdentity.GrantedRoleIds`).
Code changes:
- LDAP groups now map to custom OPC UA role NodeIds in `urn:zbmom:lmxopcua:roles` namespace
- Roles stored on session identity via `GrantedRoleIds` — no username-to-role side cache
- Permission checks use `GrantedRoleIds.Contains()` instead of username extraction
- `AnonymousCanWrite` behavior is consistent regardless of LDAP state
- Galaxy namespace moved from `ns=2` to `ns=3` (roles namespace is `ns=2`)
No configuration changes required.
Verification (instance1, port 4840):
```
anonymous read → allowed
anonymous write → denied (BadUserAccessDenied, AnonymousCanWrite=false)
readonly write → denied (BadUserAccessDenied)
readwrite write → allowed
admin write → allowed
alarmack write → denied (BadUserAccessDenied)
bad password → rejected (connection failed)
```
## Granular Write Roles Update
Updated: `2026-03-28`
Both instances updated with granular write roles replacing the single ReadWrite role.
Code changes:
- `ReadWrite` role replaced by `WriteOperate`, `WriteTune`, `WriteConfigure`
- Write permission checks now consider the Galaxy security classification of the target attribute
- `SecurityClassification` stored in `TagMetadata` for per-node lookup at write time
GLAuth changes:
- New groups: `WriteOperate` (5502), `WriteTune` (5504), `WriteConfigure` (5505)
- New users: `writeop`, `writetune`, `writeconfig`
- `admin` user added to all groups (5502, 5503, 5504, 5505)
Config changes (both instances):
- `Authentication.Ldap.ReadWriteGroup` replaced by `WriteOperateGroup`, `WriteTuneGroup`, `WriteConfigureGroup`
Verification (instance1, port 4840, Operate-classified attributes):
```
anonymous read → allowed
anonymous write → denied (AnonymousCanWrite=false)
readonly write → denied (no write role)
writeop write → allowed (WriteOperate matches Operate classification)
writetune write → denied (WriteTune doesn't match Operate)
writeconfig write → denied (WriteConfigure doesn't match Operate)
admin write → allowed (has all write roles)
```
## Notes ## 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. 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.

View File

@@ -112,8 +112,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{ {
Log.Information("Authentication.Ldap.Enabled=true, Host={Host}, Port={Port}, BaseDN={BaseDN}", Log.Information("Authentication.Ldap.Enabled=true, Host={Host}, Port={Port}, BaseDN={BaseDN}",
config.Authentication.Ldap.Host, config.Authentication.Ldap.Port, config.Authentication.Ldap.BaseDN); config.Authentication.Ldap.Host, config.Authentication.Ldap.Port, config.Authentication.Ldap.BaseDN);
Log.Information("Authentication.Ldap groups: ReadOnly={ReadOnly}, ReadWrite={ReadWrite}, AlarmAck={AlarmAck}", Log.Information("Authentication.Ldap groups: ReadOnly={ReadOnly}, WriteOperate={WriteOperate}, WriteTune={WriteTune}, WriteConfigure={WriteConfigure}, AlarmAck={AlarmAck}",
config.Authentication.Ldap.ReadOnlyGroup, config.Authentication.Ldap.ReadWriteGroup, config.Authentication.Ldap.AlarmAckGroup); config.Authentication.Ldap.ReadOnlyGroup, config.Authentication.Ldap.WriteOperateGroup,
config.Authentication.Ldap.WriteTuneGroup, config.Authentication.Ldap.WriteConfigureGroup,
config.Authentication.Ldap.AlarmAckGroup);
if (string.IsNullOrWhiteSpace(config.Authentication.Ldap.ServiceAccountDn)) if (string.IsNullOrWhiteSpace(config.Authentication.Ldap.ServiceAccountDn))
{ {

View File

@@ -55,9 +55,19 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
public string ReadOnlyGroup { get; set; } = "ReadOnly"; public string ReadOnlyGroup { get; set; } = "ReadOnly";
/// <summary> /// <summary>
/// Gets or sets the LDAP group name that grants read-write access. /// Gets or sets the LDAP group name that grants write access for FreeAccess/Operate attributes.
/// </summary> /// </summary>
public string ReadWriteGroup { get; set; } = "ReadWrite"; public string WriteOperateGroup { get; set; } = "WriteOperate";
/// <summary>
/// Gets or sets the LDAP group name that grants write access for Tune attributes.
/// </summary>
public string WriteTuneGroup { get; set; } = "WriteTune";
/// <summary>
/// Gets or sets the LDAP group name that grants write access for Configure attributes.
/// </summary>
public string WriteConfigureGroup { get; set; } = "WriteConfigure";
/// <summary> /// <summary>
/// Gets or sets the LDAP group name that grants alarm acknowledgment access. /// Gets or sets the LDAP group name that grants alarm acknowledgment access.

View File

@@ -32,7 +32,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
public static class AppRoles public static class AppRoles
{ {
public const string ReadOnly = "ReadOnly"; public const string ReadOnly = "ReadOnly";
public const string ReadWrite = "ReadWrite"; public const string WriteOperate = "WriteOperate";
public const string WriteTune = "WriteTune";
public const string WriteConfigure = "WriteConfigure";
public const string AlarmAck = "AlarmAck"; public const string AlarmAck = "AlarmAck";
} }
} }

View File

@@ -24,7 +24,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
_groupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) _groupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{ {
{ config.ReadOnlyGroup, AppRoles.ReadOnly }, { config.ReadOnlyGroup, AppRoles.ReadOnly },
{ config.ReadWriteGroup, AppRoles.ReadWrite }, { config.WriteOperateGroup, AppRoles.WriteOperate },
{ config.WriteTuneGroup, AppRoles.WriteTune },
{ config.WriteConfigureGroup, AppRoles.WriteConfigure },
{ config.AlarmAckGroup, AppRoles.AlarmAck } { config.AlarmAckGroup, AppRoles.AlarmAck }
}; };
} }

View File

@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// Stable identifiers for custom OPC UA roles mapped from LDAP groups.
/// The namespace URI is registered in the server namespace table at startup,
/// and the string identifiers are resolved to runtime NodeIds before use.
/// </summary>
public static class LmxRoleIds
{
public const string NamespaceUri = "urn:zbmom:lmxopcua:roles";
public const string ReadOnly = "Role.ReadOnly";
public const string WriteOperate = "Role.WriteOperate";
public const string WriteTune = "Role.WriteTune";
public const string WriteConfigure = "Role.WriteConfigure";
public const string AlarmAck = "Role.AlarmAck";
}
}

View File

@@ -25,7 +25,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private readonly HistorianDataSource? _historianDataSource; private readonly HistorianDataSource? _historianDataSource;
private readonly bool _alarmTrackingEnabled; private readonly bool _alarmTrackingEnabled;
private readonly bool _anonymousCanWrite; private readonly bool _anonymousCanWrite;
private readonly Func<string?, IReadOnlyList<string>?>? _appRoleLookup; private readonly NodeId? _writeOperateRoleId;
private readonly NodeId? _writeTuneRoleId;
private readonly NodeId? _writeConfigureRoleId;
private readonly NodeId? _alarmAckRoleId;
private readonly string _namespaceUri; private readonly string _namespaceUri;
// NodeId → full_tag_reference for read/write resolution // NodeId → full_tag_reference for read/write resolution
@@ -70,6 +73,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// Gets or sets the declared array length from Galaxy metadata when the attribute is modeled as an array. /// Gets or sets the declared array length from Galaxy metadata when the attribute is modeled as an array.
/// </summary> /// </summary>
public int? ArrayDimension { get; set; } public int? ArrayDimension { get; set; }
/// <summary>
/// Gets or sets the Galaxy security classification (0=FreeAccess, 1=Operate, 4=Tune, 5=Configure, etc.).
/// Used at write time to determine which write role is required.
/// </summary>
public int SecurityClassification { get; set; }
} }
// Alarm tracking: maps InAlarm tag reference → alarm source info // Alarm tracking: maps InAlarm tag reference → alarm source info
@@ -194,7 +203,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
HistorianDataSource? historianDataSource = null, HistorianDataSource? historianDataSource = null,
bool alarmTrackingEnabled = false, bool alarmTrackingEnabled = false,
bool anonymousCanWrite = true, bool anonymousCanWrite = true,
Func<string?, IReadOnlyList<string>?>? appRoleLookup = null) NodeId? writeOperateRoleId = null,
NodeId? writeTuneRoleId = null,
NodeId? writeConfigureRoleId = null,
NodeId? alarmAckRoleId = null)
: base(server, configuration, namespaceUri) : base(server, configuration, namespaceUri)
{ {
_namespaceUri = namespaceUri; _namespaceUri = namespaceUri;
@@ -203,7 +215,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
_historianDataSource = historianDataSource; _historianDataSource = historianDataSource;
_alarmTrackingEnabled = alarmTrackingEnabled; _alarmTrackingEnabled = alarmTrackingEnabled;
_anonymousCanWrite = anonymousCanWrite; _anonymousCanWrite = anonymousCanWrite;
_appRoleLookup = appRoleLookup; _writeOperateRoleId = writeOperateRoleId;
_writeTuneRoleId = writeTuneRoleId;
_writeConfigureRoleId = writeConfigureRoleId;
_alarmAckRoleId = alarmAckRoleId;
// Wire up data change delivery // Wire up data change delivery
_mxAccessClient.OnTagValueChanged += OnMxAccessDataChange; _mxAccessClient.OnTagValueChanged += OnMxAccessDataChange;
@@ -955,7 +970,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
{ {
MxDataType = attr.MxDataType, MxDataType = attr.MxDataType,
IsArray = attr.IsArray, IsArray = attr.IsArray,
ArrayDimension = attr.ArrayDimension ArrayDimension = attr.ArrayDimension,
SecurityClassification = attr.SecurityClassification
}; };
// Track gobject → tag references for incremental sync // Track gobject → tag references for incremental sync
@@ -1085,8 +1101,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
{ {
base.Write(context, nodesToWrite, errors); base.Write(context, nodesToWrite, errors);
var canWrite = HasWritePermission(context);
for (int i = 0; i < nodesToWrite.Count; i++) for (int i = 0; i < nodesToWrite.Count; i++)
{ {
if (nodesToWrite[i].AttributeId != Attributes.Value) if (nodesToWrite[i].AttributeId != Attributes.Value)
@@ -1096,19 +1110,23 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
if (errors[i] != null && errors[i].StatusCode == StatusCodes.BadNotWritable) if (errors[i] != null && errors[i].StatusCode == StatusCodes.BadNotWritable)
continue; continue;
if (!canWrite)
{
errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied);
continue;
}
var nodeId = nodesToWrite[i].NodeId; var nodeId = nodesToWrite[i].NodeId;
if (nodeId.NamespaceIndex != NamespaceIndex) continue; if (nodeId.NamespaceIndex != NamespaceIndex) continue;
var nodeIdStr = nodeId.Identifier as string; var nodeIdStr = nodeId.Identifier as string;
if (nodeIdStr == null) continue; if (nodeIdStr == null) continue;
if (_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef)) if (!_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
continue;
// Check write permission based on the node's security classification
var secClass = _tagMetadata.TryGetValue(tagRef, out var meta) ? meta.SecurityClassification : 1;
if (!HasWritePermission(context, secClass))
{
errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied);
continue;
}
{ {
try try
{ {
@@ -1146,35 +1164,55 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
} }
} }
private bool HasWritePermission(OperationContext context) private bool HasWritePermission(OperationContext context, int securityClassification)
{ {
if (_appRoleLookup != null) var identity = context.UserIdentity;
return HasRole(context.UserIdentity, Domain.AppRoles.ReadWrite);
// Legacy behavior: reject anonymous writes when AnonymousCanWrite is false // Check anonymous sessions against AnonymousCanWrite
if (!_anonymousCanWrite && context.UserIdentity?.GrantedRoleIds != null && if (identity?.GrantedRoleIds?.Contains(ObjectIds.WellKnownRole_Anonymous) == true)
!context.UserIdentity.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_AuthenticatedUser)) return _anonymousCanWrite;
{
return false;
}
// When role-based auth is active, require the role matching the security classification
var requiredRoleId = GetRequiredWriteRole(securityClassification);
if (requiredRoleId != null)
return HasGrantedRole(identity, requiredRoleId);
// No role-based auth — authenticated users can write
return true; return true;
} }
private NodeId? GetRequiredWriteRole(int securityClassification)
{
switch (securityClassification)
{
case 0: // FreeAccess
case 1: // Operate
return _writeOperateRoleId;
case 4: // Tune
return _writeTuneRoleId;
case 5: // Configure
return _writeConfigureRoleId;
default:
// SecuredWrite (2), VerifiedWrite (3), ViewOnly (6) are read-only by AccessLevel
// but if somehow reached, require the most restrictive role
return _writeConfigureRoleId;
}
}
private bool HasAlarmAckPermission(ISystemContext context) private bool HasAlarmAckPermission(ISystemContext context)
{ {
if (_appRoleLookup == null) if (_alarmAckRoleId == null)
return true; return true;
var identity = (context as SystemContext)?.UserIdentity; var identity = (context as SystemContext)?.UserIdentity;
return HasRole(identity, Domain.AppRoles.AlarmAck); return HasGrantedRole(identity, _alarmAckRoleId);
} }
private bool HasRole(IUserIdentity? identity, string requiredRole) private static bool HasGrantedRole(IUserIdentity? identity, NodeId? roleId)
{ {
var username = identity?.GetIdentityToken() is UserNameIdentityToken token ? token.UserName : null; return roleId != null &&
var roles = _appRoleLookup!(username); identity?.GrantedRoleIds != null &&
return roles != null && roles.Contains(requiredRole); identity.GrantedRoleIds.Contains(roleId);
} }
private static void EnableEventNotifierUpChain(NodeState node) private static void EnableEventNotifierUpChain(NodeState node)

View File

@@ -1,5 +1,4 @@
using System; using System;
using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Text; using System.Text;
using Opc.Ua; using Opc.Ua;
@@ -30,7 +29,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private readonly RedundancyConfiguration _redundancyConfig; private readonly RedundancyConfiguration _redundancyConfig;
private readonly string? _applicationUri; private readonly string? _applicationUri;
private readonly ServiceLevelCalculator _serviceLevelCalculator = new ServiceLevelCalculator(); private readonly ServiceLevelCalculator _serviceLevelCalculator = new ServiceLevelCalculator();
private readonly ConcurrentDictionary<string, IReadOnlyList<string>> _userAppRoles = new ConcurrentDictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
// Resolved custom role NodeIds (populated in CreateMasterNodeManager)
private NodeId? _readOnlyRoleId;
private NodeId? _writeOperateRoleId;
private NodeId? _writeTuneRoleId;
private NodeId? _writeConfigureRoleId;
private NodeId? _alarmAckRoleId;
private LmxNodeManager? _nodeManager; private LmxNodeManager? _nodeManager;
/// <summary> /// <summary>
@@ -38,22 +44,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// </summary> /// </summary>
public LmxNodeManager? NodeManager => _nodeManager; 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> /// <summary>
/// Gets the number of active OPC UA sessions currently connected to the server. /// Gets the number of active OPC UA sessions currently connected to the server.
/// </summary> /// </summary>
@@ -85,15 +75,29 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// <inheritdoc /> /// <inheritdoc />
protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server, ApplicationConfiguration configuration) protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server, ApplicationConfiguration configuration)
{ {
// Resolve custom role NodeIds from the roles namespace
ResolveRoleNodeIds(server);
var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa"; var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa";
_nodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics, _nodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics,
_historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite, _historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite,
LdapRolesEnabled ? (Func<string?, IReadOnlyList<string>?>)GetUserAppRoles : null); _writeOperateRoleId, _writeTuneRoleId, _writeConfigureRoleId, _alarmAckRoleId);
var nodeManagers = new List<INodeManager> { _nodeManager }; var nodeManagers = new List<INodeManager> { _nodeManager };
return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray()); return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray());
} }
private void ResolveRoleNodeIds(IServerInternal server)
{
var nsIndex = server.NamespaceUris.GetIndexOrAppend(LmxRoleIds.NamespaceUri);
_readOnlyRoleId = new NodeId(LmxRoleIds.ReadOnly, nsIndex);
_writeOperateRoleId = new NodeId(LmxRoleIds.WriteOperate, nsIndex);
_writeTuneRoleId = new NodeId(LmxRoleIds.WriteTune, nsIndex);
_writeConfigureRoleId = new NodeId(LmxRoleIds.WriteConfigure, nsIndex);
_alarmAckRoleId = new NodeId(LmxRoleIds.AlarmAck, nsIndex);
Log.Debug("Resolved custom role NodeIds in namespace index {NsIndex}", nsIndex);
}
/// <inheritdoc /> /// <inheritdoc />
protected override void OnServerStarted(IServerInternal server) protected override void OnServerStarted(IServerInternal server)
{ {
@@ -155,8 +159,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// Updates the server's ServiceLevel based on current runtime health. /// Updates the server's ServiceLevel based on current runtime health.
/// Called by the service layer when MXAccess or DB health changes. /// Called by the service layer when MXAccess or DB health changes.
/// </summary> /// </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) public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected)
{ {
var level = CalculateCurrentServiceLevel(mxAccessConnected, dbConnected); var level = CalculateCurrentServiceLevel(mxAccessConnected, dbConnected);
@@ -206,11 +208,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
if (!_authConfig.AllowAnonymous) if (!_authConfig.AllowAnonymous)
throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected, "Anonymous access is disabled"); throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected, "Anonymous access is disabled");
var roles = new List<Role> { Role.Anonymous }; args.Identity = new RoleBasedIdentity(
if (_authConfig.AnonymousCanWrite) new UserIdentity(anonymousToken),
roles.Add(Role.AuthenticatedUser); new List<Role> { Role.Anonymous });
args.Identity = new RoleBasedIdentity(new UserIdentity(anonymousToken), roles);
Log.Debug("Anonymous session accepted (canWrite={CanWrite})", _authConfig.AnonymousCanWrite); Log.Debug("Anonymous session accepted (canWrite={CanWrite})", _authConfig.AnonymousCanWrite);
return; return;
} }
@@ -227,11 +227,32 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
var roles = new List<Role> { Role.AuthenticatedUser }; var roles = new List<Role> { Role.AuthenticatedUser };
// Resolve LDAP-based roles when the provider supports it if (_authProvider is IRoleProvider roleProvider)
if (_authProvider is Domain.IRoleProvider roleProvider)
{ {
var appRoles = roleProvider.GetUserRoles(userNameToken.UserName); var appRoles = roleProvider.GetUserRoles(userNameToken.UserName);
_userAppRoles[userNameToken.UserName] = appRoles;
foreach (var appRole in appRoles)
{
switch (appRole)
{
case AppRoles.ReadOnly:
if (_readOnlyRoleId != null) roles.Add(new Role(_readOnlyRoleId, AppRoles.ReadOnly));
break;
case AppRoles.WriteOperate:
if (_writeOperateRoleId != null) roles.Add(new Role(_writeOperateRoleId, AppRoles.WriteOperate));
break;
case AppRoles.WriteTune:
if (_writeTuneRoleId != null) roles.Add(new Role(_writeTuneRoleId, AppRoles.WriteTune));
break;
case AppRoles.WriteConfigure:
if (_writeConfigureRoleId != null) roles.Add(new Role(_writeConfigureRoleId, AppRoles.WriteConfigure));
break;
case AppRoles.AlarmAck:
if (_alarmAckRoleId != null) roles.Add(new Role(_alarmAckRoleId, AppRoles.AlarmAck));
break;
}
}
Log.Information("User {Username} authenticated with roles [{Roles}]", Log.Information("User {Username} authenticated with roles [{Roles}]",
userNameToken.UserName, string.Join(", ", appRoles)); userNameToken.UserName, string.Join(", ", appRoles));
} }

View File

@@ -231,7 +231,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
var policies = new UserTokenPolicyCollection(); var policies = new UserTokenPolicyCollection();
if (_authConfig.AllowAnonymous) if (_authConfig.AllowAnonymous)
policies.Add(new UserTokenPolicy(UserTokenType.Anonymous)); policies.Add(new UserTokenPolicy(UserTokenType.Anonymous));
if (_authConfig.Ldap.Enabled) if (_authConfig.Ldap.Enabled || _authProvider != null)
policies.Add(new UserTokenPolicy(UserTokenType.UserName)); policies.Add(new UserTokenPolicy(UserTokenType.UserName));
if (policies.Count == 0) if (policies.Count == 0)

View File

@@ -24,6 +24,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
private readonly IGalaxyRepository? _galaxyRepository; private readonly IGalaxyRepository? _galaxyRepository;
private readonly IMxAccessClient? _mxAccessClientOverride; private readonly IMxAccessClient? _mxAccessClientOverride;
private readonly bool _hasMxAccessClientOverride; private readonly bool _hasMxAccessClientOverride;
private readonly IUserAuthenticationProvider? _authProviderOverride;
private readonly bool _hasAuthProviderOverride;
private CancellationTokenSource? _cts; private CancellationTokenSource? _cts;
private PerformanceMetrics? _metrics; private PerformanceMetrics? _metrics;
@@ -74,13 +76,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
/// <param name="mxAccessClientOverride">An optional direct MXAccess client substitute that bypasses STA thread setup and COM interop.</param> /// <param name="mxAccessClientOverride">An optional direct MXAccess client substitute that bypasses STA thread setup and COM interop.</param>
/// <param name="hasMxAccessClientOverride">A value indicating whether the override client should be used instead of creating a client from <paramref name="mxProxy"/>.</param> /// <param name="hasMxAccessClientOverride">A value indicating whether the override client should be used instead of creating a client from <paramref name="mxProxy"/>.</param>
internal OpcUaService(AppConfiguration config, IMxProxy? mxProxy, IGalaxyRepository? galaxyRepository, internal OpcUaService(AppConfiguration config, IMxProxy? mxProxy, IGalaxyRepository? galaxyRepository,
IMxAccessClient? mxAccessClientOverride = null, bool hasMxAccessClientOverride = false) IMxAccessClient? mxAccessClientOverride = null, bool hasMxAccessClientOverride = false,
IUserAuthenticationProvider? authProviderOverride = null, bool hasAuthProviderOverride = false)
{ {
_config = config; _config = config;
_mxProxy = mxProxy; _mxProxy = mxProxy;
_galaxyRepository = galaxyRepository; _galaxyRepository = galaxyRepository;
_mxAccessClientOverride = mxAccessClientOverride; _mxAccessClientOverride = mxAccessClientOverride;
_hasMxAccessClientOverride = hasMxAccessClientOverride; _hasMxAccessClientOverride = hasMxAccessClientOverride;
_authProviderOverride = authProviderOverride;
_hasAuthProviderOverride = hasAuthProviderOverride;
} }
/// <summary> /// <summary>
@@ -162,7 +167,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
? new Historian.HistorianDataSource(_config.Historian) ? new Historian.HistorianDataSource(_config.Historian)
: null; : null;
Domain.IUserAuthenticationProvider? authProvider = null; Domain.IUserAuthenticationProvider? authProvider = null;
if (_config.Authentication.Ldap.Enabled) if (_hasAuthProviderOverride)
{
authProvider = _authProviderOverride;
}
else if (_config.Authentication.Ldap.Enabled)
{ {
authProvider = new Domain.LdapAuthenticationProvider(_config.Authentication.Ldap); authProvider = new Domain.LdapAuthenticationProvider(_config.Authentication.Ldap);
Log.Information("LDAP authentication enabled (server={Host}:{Port}, baseDN={BaseDN})", Log.Information("LDAP authentication enabled (server={Host}:{Port}, baseDN={BaseDN})",

View File

@@ -17,6 +17,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
private bool _mxProxySet; private bool _mxProxySet;
private bool _galaxyRepositorySet; private bool _galaxyRepositorySet;
private bool _mxAccessClientSet; private bool _mxAccessClientSet;
private IUserAuthenticationProvider? _authProvider;
private bool _authProviderSet;
/// <summary> /// <summary>
/// Replaces the default service configuration used by the test host. /// Replaces the default service configuration used by the test host.
@@ -116,6 +118,25 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
/// Disables the embedded dashboard so tests can focus on the runtime bridge without binding the HTTP listener. /// Disables the embedded dashboard so tests can focus on the runtime bridge without binding the HTTP listener.
/// </summary> /// </summary>
/// <returns>The current builder so additional overrides can be chained.</returns> /// <returns>The current builder so additional overrides can be chained.</returns>
/// <summary>
/// Injects a custom authentication provider for tests that need deterministic role resolution.
/// </summary>
public OpcUaServiceBuilder WithAuthProvider(IUserAuthenticationProvider? provider)
{
_authProvider = provider;
_authProviderSet = true;
return this;
}
/// <summary>
/// Sets the authentication configuration for the test host.
/// </summary>
public OpcUaServiceBuilder WithAuthentication(AuthenticationConfiguration authConfig)
{
_config.Authentication = authConfig;
return this;
}
public OpcUaServiceBuilder DisableDashboard() public OpcUaServiceBuilder DisableDashboard()
{ {
_config.Dashboard.Enabled = false; _config.Dashboard.Enabled = false;
@@ -176,7 +197,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
_mxProxySet ? _mxProxy : null, _mxProxySet ? _mxProxy : null,
_galaxyRepositorySet ? _galaxyRepository : null, _galaxyRepositorySet ? _galaxyRepository : null,
_mxAccessClientSet ? _mxAccessClient : null, _mxAccessClientSet ? _mxAccessClient : null,
_mxAccessClientSet); _mxAccessClientSet,
_authProviderSet ? _authProvider : null,
_authProviderSet);
} }
/// <summary> /// <summary>

View File

@@ -46,7 +46,9 @@
"ServiceAccountPassword": "serviceaccount123", "ServiceAccountPassword": "serviceaccount123",
"TimeoutSeconds": 5, "TimeoutSeconds": 5,
"ReadOnlyGroup": "ReadOnly", "ReadOnlyGroup": "ReadOnly",
"ReadWriteGroup": "ReadWrite", "WriteOperateGroup": "WriteOperate",
"WriteTuneGroup": "WriteTune",
"WriteConfigureGroup": "WriteConfigure",
"AlarmAckGroup": "AlarmAck" "AlarmAckGroup": "AlarmAck"
} }
}, },

View File

@@ -27,7 +27,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Authentication
config.Ldap.Port.ShouldBe(3893); config.Ldap.Port.ShouldBe(3893);
config.Ldap.BaseDN.ShouldBe("dc=lmxopcua,dc=local"); config.Ldap.BaseDN.ShouldBe("dc=lmxopcua,dc=local");
config.Ldap.ReadOnlyGroup.ShouldBe("ReadOnly"); config.Ldap.ReadOnlyGroup.ShouldBe("ReadOnly");
config.Ldap.ReadWriteGroup.ShouldBe("ReadWrite"); config.Ldap.WriteOperateGroup.ShouldBe("WriteOperate");
config.Ldap.WriteTuneGroup.ShouldBe("WriteTune");
config.Ldap.WriteConfigureGroup.ShouldBe("WriteConfigure");
config.Ldap.AlarmAckGroup.ShouldBe("AlarmAck"); config.Ldap.AlarmAckGroup.ShouldBe("AlarmAck");
config.Ldap.TimeoutSeconds.ShouldBe(5); config.Ldap.TimeoutSeconds.ShouldBe(5);
} }
@@ -101,7 +103,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Authentication
provider.ValidateCredentials("readonly", "readonly123").ShouldBeTrue(); provider.ValidateCredentials("readonly", "readonly123").ShouldBeTrue();
var roles = provider.GetUserRoles("readonly"); var roles = provider.GetUserRoles("readonly");
roles.ShouldContain("ReadOnly"); roles.ShouldContain("ReadOnly");
roles.ShouldNotContain("ReadWrite"); roles.ShouldNotContain("WriteOperate");
roles.ShouldNotContain("AlarmAck"); roles.ShouldNotContain("AlarmAck");
} }
catch (System.Exception) catch (System.Exception)
@@ -111,16 +113,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Authentication
} }
[Fact] [Fact]
public void LdapAuthenticationProvider_ReadWriteUser_HasReadWriteRole() public void LdapAuthenticationProvider_WriteOperateUser_HasWriteOperateRole()
{ {
var ldapConfig = CreateGlAuthConfig(); var ldapConfig = CreateGlAuthConfig();
var provider = new LdapAuthenticationProvider(ldapConfig); var provider = new LdapAuthenticationProvider(ldapConfig);
try try
{ {
provider.ValidateCredentials("readwrite", "readwrite123").ShouldBeTrue(); provider.ValidateCredentials("writeop", "writeop123").ShouldBeTrue();
var roles = provider.GetUserRoles("readwrite"); var roles = provider.GetUserRoles("writeop");
roles.ShouldContain("ReadWrite"); roles.ShouldContain("WriteOperate");
roles.ShouldNotContain("AlarmAck"); roles.ShouldNotContain("AlarmAck");
} }
catch (System.Exception) catch (System.Exception)
@@ -140,7 +142,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Authentication
provider.ValidateCredentials("alarmack", "alarmack123").ShouldBeTrue(); provider.ValidateCredentials("alarmack", "alarmack123").ShouldBeTrue();
var roles = provider.GetUserRoles("alarmack"); var roles = provider.GetUserRoles("alarmack");
roles.ShouldContain("AlarmAck"); roles.ShouldContain("AlarmAck");
roles.ShouldNotContain("ReadWrite"); roles.ShouldNotContain("WriteOperate");
} }
catch (System.Exception) catch (System.Exception)
{ {
@@ -159,7 +161,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Authentication
provider.ValidateCredentials("admin", "admin123").ShouldBeTrue(); provider.ValidateCredentials("admin", "admin123").ShouldBeTrue();
var roles = provider.GetUserRoles("admin"); var roles = provider.GetUserRoles("admin");
roles.ShouldContain("ReadOnly"); roles.ShouldContain("ReadOnly");
roles.ShouldContain("ReadWrite"); roles.ShouldContain("WriteOperate");
roles.ShouldContain("WriteTune");
roles.ShouldContain("WriteConfigure");
roles.ShouldContain("AlarmAck"); roles.ShouldContain("AlarmAck");
} }
catch (System.Exception) catch (System.Exception)
@@ -223,7 +227,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Authentication
ServiceAccountPassword = "serviceaccount123", ServiceAccountPassword = "serviceaccount123",
TimeoutSeconds = 5, TimeoutSeconds = 5,
ReadOnlyGroup = "ReadOnly", ReadOnlyGroup = "ReadOnly",
ReadWriteGroup = "ReadWrite", WriteOperateGroup = "WriteOperate",
WriteTuneGroup = "WriteTune",
WriteConfigureGroup = "WriteConfigure",
AlarmAckGroup = "AlarmAck" AlarmAckGroup = "AlarmAck"
}; };
} }

View File

@@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
{
/// <summary>
/// Deterministic authentication provider for integration tests.
/// Validates credentials against hardcoded username/password pairs
/// and returns configured role sets per user.
/// </summary>
internal class FakeAuthenticationProvider : IUserAuthenticationProvider, IRoleProvider
{
private readonly Dictionary<string, string> _credentials =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, IReadOnlyList<string>> _roles =
new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
public FakeAuthenticationProvider AddUser(string username, string password, params string[] roles)
{
_credentials[username] = password;
_roles[username] = roles;
return this;
}
public bool ValidateCredentials(string username, string password)
{
return _credentials.TryGetValue(username, out var expected) && expected == password;
}
public IReadOnlyList<string> GetUserRoles(string username)
{
return _roles.TryGetValue(username, out var roles) ? roles : new[] { AppRoles.ReadOnly };
}
}
}

View File

@@ -3,6 +3,7 @@ using System.Threading.Tasks;
using Xunit; using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host; using ZB.MOM.WW.LmxOpcUa.Host;
using ZB.MOM.WW.LmxOpcUa.Host.Configuration; using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
{ {
@@ -120,7 +121,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
SecurityProfileConfiguration? security = null, SecurityProfileConfiguration? security = null,
RedundancyConfiguration? redundancy = null, RedundancyConfiguration? redundancy = null,
string? applicationUri = null, string? applicationUri = null,
string? serverName = null) string? serverName = null,
AuthenticationConfiguration? authConfig = null,
IUserAuthenticationProvider? authProvider = null)
{ {
var client = mxClient ?? new FakeMxAccessClient(); var client = mxClient ?? new FakeMxAccessClient();
var r = repo ?? new FakeGalaxyRepository var r = repo ?? new FakeGalaxyRepository
@@ -142,6 +145,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
builder.WithApplicationUri(applicationUri); builder.WithApplicationUri(applicationUri);
if (serverName != null) if (serverName != null)
builder.WithGalaxyName(serverName); builder.WithGalaxyName(serverName);
if (authConfig != null)
builder.WithAuthentication(authConfig);
if (authProvider != null)
builder.WithAuthProvider(authProvider);
return new OpcUaServerFixture(builder, repo: r, mxClient: client); return new OpcUaServerFixture(builder, repo: r, mxClient: client);
} }

View File

@@ -0,0 +1,188 @@
using System.Threading.Tasks;
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
{
public class PermissionEnforcementTests
{
private static FakeAuthenticationProvider CreateTestAuthProvider()
{
return new FakeAuthenticationProvider()
.AddUser("readonly", "readonly123", AppRoles.ReadOnly)
.AddUser("writeop", "writeop123", AppRoles.WriteOperate)
.AddUser("writetune", "writetune123", AppRoles.WriteTune)
.AddUser("writeconfig", "writeconfig123", AppRoles.WriteConfigure)
.AddUser("alarmack", "alarmack123", AppRoles.AlarmAck)
.AddUser("admin", "admin123", AppRoles.ReadOnly, AppRoles.WriteOperate, AppRoles.WriteTune, AppRoles.WriteConfigure, AppRoles.AlarmAck);
}
private static AuthenticationConfiguration CreateAuthConfig(bool anonymousCanWrite = false)
{
return new AuthenticationConfiguration
{
AllowAnonymous = true,
AnonymousCanWrite = anonymousCanWrite
};
}
[Fact]
public async Task AnonymousRead_Allowed()
{
var mxClient = new FakeMxAccessClient();
mxClient.TagValues["TestMachine_001.MachineID"] = Vtq.Good("hello");
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
mxClient: mxClient,
authConfig: CreateAuthConfig(),
authProvider: CreateTestAuthProvider());
await fixture.InitializeAsync();
try
{
using var client = new OpcUaTestClient();
await client.ConnectAsync(fixture.EndpointUrl);
var result = client.Read(client.MakeNodeId("TestMachine_001.MachineID"));
result.StatusCode.ShouldNotBe(StatusCodes.BadUserAccessDenied);
}
finally { await fixture.DisposeAsync(); }
}
[Fact]
public async Task AnonymousWrite_Denied_WhenAnonymousCanWriteFalse()
{
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
authConfig: CreateAuthConfig(anonymousCanWrite: false),
authProvider: CreateTestAuthProvider());
await fixture.InitializeAsync();
try
{
using var client = new OpcUaTestClient();
await client.ConnectAsync(fixture.EndpointUrl);
var status = client.Write(client.MakeNodeId("TestMachine_001.MachineID"), "test");
status.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
}
finally { await fixture.DisposeAsync(); }
}
[Fact]
public async Task AnonymousWrite_Allowed_WhenAnonymousCanWriteTrue()
{
var mxClient = new FakeMxAccessClient();
mxClient.TagValues["TestMachine_001.MachineID"] = Vtq.Good("initial");
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
mxClient: mxClient,
authConfig: CreateAuthConfig(anonymousCanWrite: true),
authProvider: CreateTestAuthProvider());
await fixture.InitializeAsync();
try
{
using var client = new OpcUaTestClient();
await client.ConnectAsync(fixture.EndpointUrl);
var status = client.Write(client.MakeNodeId("TestMachine_001.MachineID"), "test");
status.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
}
finally { await fixture.DisposeAsync(); }
}
[Fact]
public async Task ReadOnlyUser_Write_Denied()
{
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
authConfig: CreateAuthConfig(),
authProvider: CreateTestAuthProvider());
await fixture.InitializeAsync();
try
{
using var client = new OpcUaTestClient();
await client.ConnectAsync(fixture.EndpointUrl, username: "readonly", password: "readonly123");
var status = client.Write(client.MakeNodeId("TestMachine_001.MachineID"), "test");
status.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
}
finally { await fixture.DisposeAsync(); }
}
[Fact]
public async Task WriteOperateUser_Write_Allowed()
{
var mxClient = new FakeMxAccessClient();
mxClient.TagValues["TestMachine_001.MachineID"] = Vtq.Good("initial");
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
mxClient: mxClient,
authConfig: CreateAuthConfig(),
authProvider: CreateTestAuthProvider());
await fixture.InitializeAsync();
try
{
using var client = new OpcUaTestClient();
await client.ConnectAsync(fixture.EndpointUrl, username: "writeop", password: "writeop123");
var status = client.Write(client.MakeNodeId("TestMachine_001.MachineID"), "test");
status.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
}
finally { await fixture.DisposeAsync(); }
}
[Fact]
public async Task AlarmAckOnlyUser_Write_Denied()
{
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
authConfig: CreateAuthConfig(),
authProvider: CreateTestAuthProvider());
await fixture.InitializeAsync();
try
{
using var client = new OpcUaTestClient();
await client.ConnectAsync(fixture.EndpointUrl, username: "alarmack", password: "alarmack123");
var status = client.Write(client.MakeNodeId("TestMachine_001.MachineID"), "test");
status.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
}
finally { await fixture.DisposeAsync(); }
}
[Fact]
public async Task AdminUser_Write_Allowed()
{
var mxClient = new FakeMxAccessClient();
mxClient.TagValues["TestMachine_001.MachineID"] = Vtq.Good("initial");
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
mxClient: mxClient,
authConfig: CreateAuthConfig(),
authProvider: CreateTestAuthProvider());
await fixture.InitializeAsync();
try
{
using var client = new OpcUaTestClient();
await client.ConnectAsync(fixture.EndpointUrl, username: "admin", password: "admin123");
var status = client.Write(client.MakeNodeId("TestMachine_001.MachineID"), "test");
status.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
}
finally { await fixture.DisposeAsync(); }
}
[Fact]
public async Task InvalidPassword_ConnectionRejected()
{
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
authConfig: CreateAuthConfig(),
authProvider: CreateTestAuthProvider());
await fixture.InitializeAsync();
try
{
using var client = new OpcUaTestClient();
await Should.ThrowAsync<ServiceResultException>(async () =>
await client.ConnectAsync(fixture.EndpointUrl, username: "readonly", password: "wrongpassword"));
}
finally { await fixture.DisposeAsync(); }
}
}
}