From 50b85d41bd08119b58f01fd2aff82270ec1cb20e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 29 Mar 2026 01:50:16 -0400 Subject: [PATCH] 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) --- CLAUDE.md | 2 +- docs/AlarmTracking.md | 2 +- docs/Configuration.md | 18 +- docs/OpcUaServer.md | 8 +- docs/security.md | 37 +++- service_info.md | 56 ++++++ .../Configuration/ConfigurationValidator.cs | 6 +- .../Configuration/LdapConfiguration.cs | 14 +- .../Domain/IUserAuthenticationProvider.cs | 4 +- .../Domain/LdapAuthenticationProvider.cs | 4 +- .../Domain/LmxRoleIds.cs | 18 ++ .../OpcUa/LmxNodeManager.cs | 94 ++++++--- .../OpcUa/LmxOpcUaServer.cs | 79 +++++--- .../OpcUa/OpcUaServerHost.cs | 2 +- src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs | 13 +- .../OpcUaServiceBuilder.cs | 25 ++- src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json | 4 +- .../Authentication/UserAuthenticationTests.cs | 24 ++- .../Helpers/FakeAuthenticationProvider.cs | 36 ++++ .../Helpers/OpcUaServerFixture.cs | 9 +- .../Integration/PermissionEnforcementTests.cs | 188 ++++++++++++++++++ 21 files changed, 549 insertions(+), 94 deletions(-) create mode 100644 src/ZB.MOM.WW.LmxOpcUa.Host/Domain/LmxRoleIds.cs create mode 100644 tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/FakeAuthenticationProvider.cs create mode 100644 tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/PermissionEnforcementTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 69df25c..83e1e94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,7 +87,7 @@ The server supports non-transparent warm/hot redundancy via the `Redundancy` sec ## 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 diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md index 5f19896..1ecff6e 100644 --- a/docs/AlarmTracking.md +++ b/docs/AlarmTracking.md @@ -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}"`. - **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. -- **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. diff --git a/docs/Configuration.md b/docs/Configuration.md index ee2d6af..a3f8327 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -130,21 +130,27 @@ When `Ldap.Enabled` is `true`, credentials are validated against the configured | `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.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 | #### 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 | |---|---| -| ReadOnly | Browse and read nodes | -| ReadWrite | Browse, read, and write tag values | +| ReadOnly | No additional permissions (read-only access) | +| WriteOperate | Write FreeAccess and Operate attributes | +| WriteTune | Write Tune attributes | +| WriteConfigure | Write Configure attributes | | AlarmAck | Acknowledge alarms | 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: ```json @@ -161,7 +167,9 @@ Example configuration: "ServiceAccountPassword": "serviceaccount123", "TimeoutSeconds": 5, "ReadOnlyGroup": "ReadOnly", - "ReadWriteGroup": "ReadWrite", + "WriteOperateGroup": "WriteOperate", + "WriteTuneGroup": "WriteTune", + "WriteConfigureGroup": "WriteConfigure", "AlarmAckGroup": "AlarmAck" } } diff --git a/docs/OpcUaServer.md b/docs/OpcUaServer.md index 39e4686..597eb80 100644 --- a/docs/OpcUaServer.md +++ b/docs/OpcUaServer.md @@ -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`: - 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. ### 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 diff --git a/docs/security.md b/docs/security.md index 9ba09c0..6780511 100644 --- a/docs/security.md +++ b/docs/security.md @@ -266,25 +266,50 @@ The CLI tool auto-generates its own client certificate on first use (stored unde ## 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 ``` OPC UA Client → UserName Token → LmxOpcUa Server → LDAP Bind (validate credentials) → 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 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 | -| ReadWrite | Read and write tag values | +| ReadOnly | No additional permissions (read-only access) | +| WriteOperate | Write FreeAccess and Operate attributes | +| WriteTune | Write Tune attributes | +| WriteConfigure | Write Configure attributes | | 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 diff --git a/service_info.md b/service_info.md index 20912b1..f0028e3 100644 --- a/service_info.md +++ b/service_info.md @@ -143,6 +143,62 @@ alarms --node DEV --refresh: 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 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. diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs index 57ddca8..ef35e24 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs @@ -112,8 +112,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration { 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); + Log.Information("Authentication.Ldap groups: ReadOnly={ReadOnly}, WriteOperate={WriteOperate}, WriteTune={WriteTune}, WriteConfigure={WriteConfigure}, AlarmAck={AlarmAck}", + 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)) { diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/LdapConfiguration.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/LdapConfiguration.cs index 3ddcd86..b24ca32 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/LdapConfiguration.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/LdapConfiguration.cs @@ -55,9 +55,19 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration public string ReadOnlyGroup { get; set; } = "ReadOnly"; /// - /// 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. /// - public string ReadWriteGroup { get; set; } = "ReadWrite"; + public string WriteOperateGroup { get; set; } = "WriteOperate"; + + /// + /// Gets or sets the LDAP group name that grants write access for Tune attributes. + /// + public string WriteTuneGroup { get; set; } = "WriteTune"; + + /// + /// Gets or sets the LDAP group name that grants write access for Configure attributes. + /// + public string WriteConfigureGroup { get; set; } = "WriteConfigure"; /// /// Gets or sets the LDAP group name that grants alarm acknowledgment access. diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IUserAuthenticationProvider.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IUserAuthenticationProvider.cs index 35c9e9c..f2768cc 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IUserAuthenticationProvider.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IUserAuthenticationProvider.cs @@ -32,7 +32,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain public static class AppRoles { 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"; } } diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/LdapAuthenticationProvider.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/LdapAuthenticationProvider.cs index 7b5bd53..8163440 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/LdapAuthenticationProvider.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/LdapAuthenticationProvider.cs @@ -24,7 +24,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain _groupToRole = new Dictionary(StringComparer.OrdinalIgnoreCase) { { config.ReadOnlyGroup, AppRoles.ReadOnly }, - { config.ReadWriteGroup, AppRoles.ReadWrite }, + { config.WriteOperateGroup, AppRoles.WriteOperate }, + { config.WriteTuneGroup, AppRoles.WriteTune }, + { config.WriteConfigureGroup, AppRoles.WriteConfigure }, { config.AlarmAckGroup, AppRoles.AlarmAck } }; } diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/LmxRoleIds.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/LmxRoleIds.cs new file mode 100644 index 0000000..f5edfaa --- /dev/null +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/LmxRoleIds.cs @@ -0,0 +1,18 @@ +namespace ZB.MOM.WW.LmxOpcUa.Host.Domain +{ + /// + /// 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. + /// + 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"; + } +} diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs index a0b5479..c4dec47 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs @@ -25,7 +25,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa private readonly HistorianDataSource? _historianDataSource; private readonly bool _alarmTrackingEnabled; private readonly bool _anonymousCanWrite; - private readonly Func?>? _appRoleLookup; + private readonly NodeId? _writeOperateRoleId; + private readonly NodeId? _writeTuneRoleId; + private readonly NodeId? _writeConfigureRoleId; + private readonly NodeId? _alarmAckRoleId; private readonly string _namespaceUri; // 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. /// public int? ArrayDimension { get; set; } + + /// + /// 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. + /// + public int SecurityClassification { get; set; } } // Alarm tracking: maps InAlarm tag reference → alarm source info @@ -194,7 +203,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa HistorianDataSource? historianDataSource = null, bool alarmTrackingEnabled = false, bool anonymousCanWrite = true, - Func?>? appRoleLookup = null) + NodeId? writeOperateRoleId = null, + NodeId? writeTuneRoleId = null, + NodeId? writeConfigureRoleId = null, + NodeId? alarmAckRoleId = null) : base(server, configuration, namespaceUri) { _namespaceUri = namespaceUri; @@ -203,7 +215,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa _historianDataSource = historianDataSource; _alarmTrackingEnabled = alarmTrackingEnabled; _anonymousCanWrite = anonymousCanWrite; - _appRoleLookup = appRoleLookup; + _writeOperateRoleId = writeOperateRoleId; + _writeTuneRoleId = writeTuneRoleId; + _writeConfigureRoleId = writeConfigureRoleId; + _alarmAckRoleId = alarmAckRoleId; // Wire up data change delivery _mxAccessClient.OnTagValueChanged += OnMxAccessDataChange; @@ -955,7 +970,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { MxDataType = attr.MxDataType, IsArray = attr.IsArray, - ArrayDimension = attr.ArrayDimension + ArrayDimension = attr.ArrayDimension, + SecurityClassification = attr.SecurityClassification }; // Track gobject → tag references for incremental sync @@ -1085,8 +1101,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { base.Write(context, nodesToWrite, errors); - var canWrite = HasWritePermission(context); - for (int i = 0; i < nodesToWrite.Count; i++) { 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) continue; - if (!canWrite) - { - errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied); - continue; - } - var nodeId = nodesToWrite[i].NodeId; if (nodeId.NamespaceIndex != NamespaceIndex) continue; var nodeIdStr = nodeId.Identifier as string; 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 { @@ -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) - return HasRole(context.UserIdentity, Domain.AppRoles.ReadWrite); + var identity = context.UserIdentity; - // Legacy behavior: reject anonymous writes when AnonymousCanWrite is false - if (!_anonymousCanWrite && context.UserIdentity?.GrantedRoleIds != null && - !context.UserIdentity.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_AuthenticatedUser)) - { - return false; - } + // Check anonymous sessions against AnonymousCanWrite + if (identity?.GrantedRoleIds?.Contains(ObjectIds.WellKnownRole_Anonymous) == true) + return _anonymousCanWrite; + // 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; } + 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) { - if (_appRoleLookup == null) + if (_alarmAckRoleId == null) return true; 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; - var roles = _appRoleLookup!(username); - return roles != null && roles.Contains(requiredRole); + return roleId != null && + identity?.GrantedRoleIds != null && + identity.GrantedRoleIds.Contains(roleId); } private static void EnableEventNotifierUpChain(NodeState node) diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs index bfbc2af..e026cbe 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Text; using Opc.Ua; @@ -30,7 +29,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa private readonly RedundancyConfiguration _redundancyConfig; private readonly string? _applicationUri; private readonly ServiceLevelCalculator _serviceLevelCalculator = new ServiceLevelCalculator(); - private readonly ConcurrentDictionary> _userAppRoles = new ConcurrentDictionary>(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; /// @@ -38,22 +44,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa /// public LmxNodeManager? NodeManager => _nodeManager; - /// - /// 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. - /// - public IReadOnlyList? GetUserAppRoles(string? username) - { - if (username != null && _userAppRoles.TryGetValue(username, out var roles)) - return roles; - return null; - } - - /// - /// Gets whether LDAP role-based access control is active. - /// - public bool LdapRolesEnabled => _authProvider is Domain.IRoleProvider; - /// /// Gets the number of active OPC UA sessions currently connected to the server. /// @@ -85,15 +75,29 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa /// protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server, ApplicationConfiguration configuration) { + // Resolve custom role NodeIds from the roles namespace + ResolveRoleNodeIds(server); + var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa"; _nodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics, _historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite, - LdapRolesEnabled ? (Func?>)GetUserAppRoles : null); + _writeOperateRoleId, _writeTuneRoleId, _writeConfigureRoleId, _alarmAckRoleId); var nodeManagers = new List { _nodeManager }; 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); + } + /// 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. /// Called by the service layer when MXAccess or DB health changes. /// - /// Whether the MXAccess connection is healthy. - /// Whether the Galaxy repository database is reachable. public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected) { var level = CalculateCurrentServiceLevel(mxAccessConnected, dbConnected); @@ -206,11 +208,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa if (!_authConfig.AllowAnonymous) throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected, "Anonymous access is disabled"); - var roles = new List { Role.Anonymous }; - if (_authConfig.AnonymousCanWrite) - roles.Add(Role.AuthenticatedUser); - - args.Identity = new RoleBasedIdentity(new UserIdentity(anonymousToken), roles); + args.Identity = new RoleBasedIdentity( + new UserIdentity(anonymousToken), + new List { Role.Anonymous }); Log.Debug("Anonymous session accepted (canWrite={CanWrite})", _authConfig.AnonymousCanWrite); return; } @@ -227,11 +227,32 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa var roles = new List { Role.AuthenticatedUser }; - // Resolve LDAP-based roles when the provider supports it - if (_authProvider is Domain.IRoleProvider roleProvider) + if (_authProvider is IRoleProvider roleProvider) { 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}]", userNameToken.UserName, string.Join(", ", appRoles)); } diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs index fb7ce83..9b5480c 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs @@ -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.Ldap.Enabled) + if (_authConfig.Ldap.Enabled || _authProvider != null) policies.Add(new UserTokenPolicy(UserTokenType.UserName)); if (policies.Count == 0) diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs index 666c6c7..00af714 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs @@ -24,6 +24,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host private readonly IGalaxyRepository? _galaxyRepository; private readonly IMxAccessClient? _mxAccessClientOverride; private readonly bool _hasMxAccessClientOverride; + private readonly IUserAuthenticationProvider? _authProviderOverride; + private readonly bool _hasAuthProviderOverride; private CancellationTokenSource? _cts; private PerformanceMetrics? _metrics; @@ -74,13 +76,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Host /// An optional direct MXAccess client substitute that bypasses STA thread setup and COM interop. /// A value indicating whether the override client should be used instead of creating a client from . 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; _mxProxy = mxProxy; _galaxyRepository = galaxyRepository; _mxAccessClientOverride = mxAccessClientOverride; _hasMxAccessClientOverride = hasMxAccessClientOverride; + _authProviderOverride = authProviderOverride; + _hasAuthProviderOverride = hasAuthProviderOverride; } /// @@ -162,7 +167,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host ? new Historian.HistorianDataSource(_config.Historian) : 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); Log.Information("LDAP authentication enabled (server={Host}:{Port}, baseDN={BaseDN})", diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaServiceBuilder.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaServiceBuilder.cs index 55e4dd2..6b9459b 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaServiceBuilder.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaServiceBuilder.cs @@ -17,6 +17,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host private bool _mxProxySet; private bool _galaxyRepositorySet; private bool _mxAccessClientSet; + private IUserAuthenticationProvider? _authProvider; + private bool _authProviderSet; /// /// 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. /// /// The current builder so additional overrides can be chained. + /// + /// Injects a custom authentication provider for tests that need deterministic role resolution. + /// + public OpcUaServiceBuilder WithAuthProvider(IUserAuthenticationProvider? provider) + { + _authProvider = provider; + _authProviderSet = true; + return this; + } + + /// + /// Sets the authentication configuration for the test host. + /// + public OpcUaServiceBuilder WithAuthentication(AuthenticationConfiguration authConfig) + { + _config.Authentication = authConfig; + return this; + } + public OpcUaServiceBuilder DisableDashboard() { _config.Dashboard.Enabled = false; @@ -176,7 +197,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host _mxProxySet ? _mxProxy : null, _galaxyRepositorySet ? _galaxyRepository : null, _mxAccessClientSet ? _mxAccessClient : null, - _mxAccessClientSet); + _mxAccessClientSet, + _authProviderSet ? _authProvider : null, + _authProviderSet); } /// diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json b/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json index d7d8c94..6c9f309 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json @@ -46,7 +46,9 @@ "ServiceAccountPassword": "serviceaccount123", "TimeoutSeconds": 5, "ReadOnlyGroup": "ReadOnly", - "ReadWriteGroup": "ReadWrite", + "WriteOperateGroup": "WriteOperate", + "WriteTuneGroup": "WriteTune", + "WriteConfigureGroup": "WriteConfigure", "AlarmAckGroup": "AlarmAck" } }, diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Authentication/UserAuthenticationTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Authentication/UserAuthenticationTests.cs index b45795c..80ebbe1 100644 --- a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Authentication/UserAuthenticationTests.cs +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Authentication/UserAuthenticationTests.cs @@ -27,7 +27,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Authentication 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.WriteOperateGroup.ShouldBe("WriteOperate"); + config.Ldap.WriteTuneGroup.ShouldBe("WriteTune"); + config.Ldap.WriteConfigureGroup.ShouldBe("WriteConfigure"); config.Ldap.AlarmAckGroup.ShouldBe("AlarmAck"); config.Ldap.TimeoutSeconds.ShouldBe(5); } @@ -101,7 +103,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Authentication provider.ValidateCredentials("readonly", "readonly123").ShouldBeTrue(); var roles = provider.GetUserRoles("readonly"); roles.ShouldContain("ReadOnly"); - roles.ShouldNotContain("ReadWrite"); + roles.ShouldNotContain("WriteOperate"); roles.ShouldNotContain("AlarmAck"); } catch (System.Exception) @@ -111,16 +113,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Authentication } [Fact] - public void LdapAuthenticationProvider_ReadWriteUser_HasReadWriteRole() + public void LdapAuthenticationProvider_WriteOperateUser_HasWriteOperateRole() { var ldapConfig = CreateGlAuthConfig(); var provider = new LdapAuthenticationProvider(ldapConfig); try { - provider.ValidateCredentials("readwrite", "readwrite123").ShouldBeTrue(); - var roles = provider.GetUserRoles("readwrite"); - roles.ShouldContain("ReadWrite"); + provider.ValidateCredentials("writeop", "writeop123").ShouldBeTrue(); + var roles = provider.GetUserRoles("writeop"); + roles.ShouldContain("WriteOperate"); roles.ShouldNotContain("AlarmAck"); } catch (System.Exception) @@ -140,7 +142,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Authentication provider.ValidateCredentials("alarmack", "alarmack123").ShouldBeTrue(); var roles = provider.GetUserRoles("alarmack"); roles.ShouldContain("AlarmAck"); - roles.ShouldNotContain("ReadWrite"); + roles.ShouldNotContain("WriteOperate"); } catch (System.Exception) { @@ -159,7 +161,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Authentication provider.ValidateCredentials("admin", "admin123").ShouldBeTrue(); var roles = provider.GetUserRoles("admin"); roles.ShouldContain("ReadOnly"); - roles.ShouldContain("ReadWrite"); + roles.ShouldContain("WriteOperate"); + roles.ShouldContain("WriteTune"); + roles.ShouldContain("WriteConfigure"); roles.ShouldContain("AlarmAck"); } catch (System.Exception) @@ -223,7 +227,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Authentication ServiceAccountPassword = "serviceaccount123", TimeoutSeconds = 5, ReadOnlyGroup = "ReadOnly", - ReadWriteGroup = "ReadWrite", + WriteOperateGroup = "WriteOperate", + WriteTuneGroup = "WriteTune", + WriteConfigureGroup = "WriteConfigure", AlarmAckGroup = "AlarmAck" }; } diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/FakeAuthenticationProvider.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/FakeAuthenticationProvider.cs new file mode 100644 index 0000000..9695ba5 --- /dev/null +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/FakeAuthenticationProvider.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using ZB.MOM.WW.LmxOpcUa.Host.Domain; + +namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers +{ + /// + /// Deterministic authentication provider for integration tests. + /// Validates credentials against hardcoded username/password pairs + /// and returns configured role sets per user. + /// + internal class FakeAuthenticationProvider : IUserAuthenticationProvider, IRoleProvider + { + private readonly Dictionary _credentials = + new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _roles = + new Dictionary>(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 GetUserRoles(string username) + { + return _roles.TryGetValue(username, out var roles) ? roles : new[] { AppRoles.ReadOnly }; + } + } +} diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixture.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixture.cs index ada98ce..291fae7 100644 --- a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixture.cs +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Helpers/OpcUaServerFixture.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Xunit; using ZB.MOM.WW.LmxOpcUa.Host; using ZB.MOM.WW.LmxOpcUa.Host.Configuration; +using ZB.MOM.WW.LmxOpcUa.Host.Domain; namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers { @@ -120,7 +121,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers SecurityProfileConfiguration? security = null, RedundancyConfiguration? redundancy = null, string? applicationUri = null, - string? serverName = null) + string? serverName = null, + AuthenticationConfiguration? authConfig = null, + IUserAuthenticationProvider? authProvider = null) { var client = mxClient ?? new FakeMxAccessClient(); var r = repo ?? new FakeGalaxyRepository @@ -142,6 +145,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers builder.WithApplicationUri(applicationUri); if (serverName != null) builder.WithGalaxyName(serverName); + if (authConfig != null) + builder.WithAuthentication(authConfig); + if (authProvider != null) + builder.WithAuthProvider(authProvider); return new OpcUaServerFixture(builder, repo: r, mxClient: client); } diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/PermissionEnforcementTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/PermissionEnforcementTests.cs new file mode 100644 index 0000000..4feed31 --- /dev/null +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/PermissionEnforcementTests.cs @@ -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(async () => + await client.ConnectAsync(fixture.EndpointUrl, username: "readonly", password: "wrongpassword")); + } + finally { await fixture.DisposeAsync(); } + } + } +}