diff --git a/auth_update.md b/auth_update.md new file mode 100644 index 0000000..0124d88 --- /dev/null +++ b/auth_update.md @@ -0,0 +1,231 @@ +# Authentication and Role-Based Access Control Plan + +## Context + +The server currently accepts only anonymous connections with no write restrictions beyond Galaxy `security_classification`. This plan adds configurable authentication (anonymous + username/password) and role-based write control using the OPC UA framework's built-in RBAC support. + +## Configuration + +Add an `Authentication` section to `appsettings.json`: + +```json +{ + "Authentication": { + "AllowAnonymous": true, + "AnonymousCanWrite": true, + "Users": [ + { "Username": "operator", "Password": "op123" }, + { "Username": "engineer", "Password": "eng456" } + ] + } +} +``` + +| Setting | Default | Description | +|---|---|---| +| `AllowAnonymous` | `true` | Accept anonymous OPC UA connections | +| `AnonymousCanWrite` | `true` | Allow anonymous users to write tag values (respecting existing security classification) | +| `Users` | `[]` | Username/password pairs for authenticated access | + +## Roles + +Two roles using OPC UA well-known role NodeIds: + +| Role | NodeId | Permissions | +|---|---|---| +| `Anonymous` | `ObjectIds.WellKnownRole_Anonymous` | Browse + Read (+ Write if `AnonymousCanWrite` is true) | +| `AuthenticatedUser` | `ObjectIds.WellKnownRole_AuthenticatedUser` | Browse + Read + Write | + +## Design + +### 1. Configuration class — `AuthenticationConfiguration.cs` + +```csharp +public class AuthenticationConfiguration +{ + public bool AllowAnonymous { get; set; } = true; + public bool AnonymousCanWrite { get; set; } = true; + public List Users { get; set; } = new(); +} + +public class UserCredential +{ + public string Username { get; set; } = ""; + public string Password { get; set; } = ""; +} +``` + +Add to `AppConfiguration` and bind in `OpcUaService`. + +### 2. User authentication provider — `IUserAuthenticationProvider` + +Pluggable interface so the backing store can be swapped (file-based now, LDAP later): + +```csharp +public interface IUserAuthenticationProvider +{ + bool ValidateCredentials(string username, string password); +} +``` + +**File-based implementation** — `ConfigUserAuthenticationProvider`: + +```csharp +public class ConfigUserAuthenticationProvider : IUserAuthenticationProvider +{ + private readonly Dictionary _users; + + public ConfigUserAuthenticationProvider(List users) + { + _users = users.ToDictionary(u => u.Username, u => u.Password, StringComparer.OrdinalIgnoreCase); + } + + public bool ValidateCredentials(string username, string password) + { + return _users.TryGetValue(username, out var expected) && expected == password; + } +} +``` + +### 3. UserTokenPolicies — `OpcUaServerHost.cs` + +Update the `ServerConfiguration.UserTokenPolicies` based on config: + +```csharp +var policies = new UserTokenPolicyCollection(); +if (authConfig.AllowAnonymous) + policies.Add(new UserTokenPolicy(UserTokenType.Anonymous)); +if (authConfig.Users.Count > 0) + policies.Add(new UserTokenPolicy(UserTokenType.UserName)); +``` + +If `AllowAnonymous` is false and no users are configured, the server cannot accept connections — log an error. + +### 4. Session impersonation — `LmxOpcUaServer.cs` + +Override `CreateMasterNodeManager` or startup initialization to register the `ImpersonateUser` event: + +```csharp +protected override void OnServerStarted(IServerInternal server) +{ + base.OnServerStarted(server); + server.SessionManager.ImpersonateUser += OnImpersonateUser; +} +``` + +Implement `OnImpersonateUser`: + +```csharp +private void OnImpersonateUser(Session session, ImpersonateEventArgs args) +{ + if (args.NewIdentity is AnonymousIdentityToken) + { + if (!_authConfig.AllowAnonymous) + throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected); + + var roles = new NodeIdCollection { ObjectIds.WellKnownRole_Anonymous }; + if (_authConfig.AnonymousCanWrite) + roles.Add(ObjectIds.WellKnownRole_AuthenticatedUser); // grants write via RBAC + args.Identity = new RoleBasedIdentity(new UserIdentity(args.NewIdentity as AnonymousIdentityToken), roles); + return; + } + + if (args.NewIdentity is UserNameIdentityToken userNameToken) + { + var password = Encoding.UTF8.GetString(userNameToken.DecryptedPassword); + if (!_authProvider.ValidateCredentials(userNameToken.UserName, password)) + throw new ServiceResultException(StatusCodes.BadUserAccessDenied); + + args.Identity = new RoleBasedIdentity( + new UserIdentity(userNameToken), + new NodeIdCollection { ObjectIds.WellKnownRole_AuthenticatedUser }); + return; + } + + throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected); +} +``` + +### 5. Write access enforcement — `LmxNodeManager.cs` + +The OPC UA framework already enforces `RolePermissions` on nodes via `base.Write()`. To use this, set `RolePermissions` on variable nodes during address space build. + +In `CreateAttributeVariable`, after setting `AccessLevel`: + +```csharp +variable.RolePermissions = new RolePermissionTypeCollection +{ + new RolePermissionType + { + RoleId = ObjectIds.WellKnownRole_Anonymous, + Permissions = (uint)(PermissionType.Browse | PermissionType.Read | PermissionType.ReadRolePermissions) + }, + new RolePermissionType + { + RoleId = ObjectIds.WellKnownRole_AuthenticatedUser, + Permissions = (uint)(PermissionType.Browse | PermissionType.Read | PermissionType.ReadRolePermissions | PermissionType.Write) + } +}; +``` + +When `AnonymousCanWrite` is true, add `PermissionType.Write` to the Anonymous role permissions. + +**Alternative simpler approach**: Skip per-node `RolePermissions` and check roles manually in the `Write` override: + +```csharp +// In Write override, before processing: +if (context.UserIdentity?.GrantedRoleIds != null && + !context.UserIdentity.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_AuthenticatedUser)) +{ + errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied); + continue; +} +``` + +The manual approach is simpler and avoids setting `RolePermissions` on every variable node. Use this for Phase 1. + +### 6. Wire configuration through the stack + +Pass `AuthenticationConfiguration` and `IUserAuthenticationProvider` through: +- `OpcUaService` → `OpcUaServerHost` → `LmxOpcUaServer` +- `LmxOpcUaServer` uses them in `OnImpersonateUser` +- `OpcUaServerHost` uses `AllowAnonymous` and user count for `UserTokenPolicies` +- `LmxNodeManager` receives `AnonymousCanWrite` flag for write enforcement + +## Files to Create/Modify + +| File | Change | +|---|---| +| `src/.../Configuration/AuthenticationConfiguration.cs` | NEW — config class with AllowAnonymous, AnonymousCanWrite, Users | +| `src/.../Configuration/AppConfiguration.cs` | Add `Authentication` property | +| `src/.../Domain/IUserAuthenticationProvider.cs` | NEW — pluggable auth interface | +| `src/.../Domain/ConfigUserAuthenticationProvider.cs` | NEW — file-based implementation | +| `src/.../OpcUa/OpcUaServerHost.cs` | Dynamic UserTokenPolicies, pass auth config | +| `src/.../OpcUa/LmxOpcUaServer.cs` | Register ImpersonateUser handler, validate credentials | +| `src/.../OpcUa/LmxNodeManager.cs` | Check user role in Write override when AnonymousCanWrite=false | +| `src/.../OpcUaService.cs` | Bind Authentication config, create provider, pass through | +| `src/.../appsettings.json` | Add Authentication section | +| `tests/.../Authentication/UserAuthenticationTests.cs` | NEW — credential validation tests | +| `tests/.../Integration/WriteAccessTests.cs` | NEW — anonymous vs authenticated write tests | +| `docs/Configuration.md` | Update with Authentication section | + +## Verification + +1. Build clean, all tests pass +2. Deploy with `AllowAnonymous: true, AnonymousCanWrite: true` — current behavior preserved +3. Deploy with `AllowAnonymous: true, AnonymousCanWrite: false`: + - Anonymous connect succeeds, reads work + - Anonymous writes rejected with `BadUserAccessDenied` + - Authenticated user writes succeed +4. Deploy with `AllowAnonymous: false`: + - Anonymous connect rejected + - Username/password connect succeeds +5. CLI tool: `connect` with and without credentials +6. Invalid credentials → `BadUserAccessDenied` + +## Future Extensions + +- **LDAP provider**: Implement `IUserAuthenticationProvider` backed by LDAP/Active Directory +- **Certificate auth**: Add `UserTokenType.Certificate` policy and X.509 validation +- **Per-tag permissions**: Map Galaxy security groups to OPC UA roles for fine-grained access +- **Audit logging**: Log authentication events (connect, disconnect, access denied) diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs index 8e5ac2e..fab586b 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs @@ -72,6 +72,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa // Alarm tracking: maps InAlarm tag reference → alarm source info private readonly Dictionary _alarmInAlarmTags = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _alarmAckedTags = new Dictionary(StringComparer.OrdinalIgnoreCase); // Incremental sync: persistent node map and reverse lookup private readonly Dictionary _nodeMap = new Dictionary(); @@ -125,6 +126,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa /// Gets or sets the cached alarm message used when emitting active and cleared events. /// public string CachedMessage { get; set; } = ""; + + /// + /// Gets or sets the Galaxy tag reference for the alarm acknowledged state. + /// + public string AckedTagReference { get; set; } = ""; + + /// + /// Gets or sets the Galaxy tag reference for the acknowledge message that triggers acknowledgment. + /// + public string AckMsgTagReference { get; set; } = ""; } /// @@ -218,6 +229,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa _tagToVariableNode.Clear(); _tagMetadata.Clear(); _alarmInAlarmTags.Clear(); + _alarmAckedTags.Clear(); _nodeMap.Clear(); _gobjectToTagRefs.Clear(); VariableNodeCount = 0; @@ -377,6 +389,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa condition.SetSeverity(SystemContext, EventSeverity.Medium); condition.Retain.Value = false; condition.OnReportEvent = (context, node, e) => Server.ReportEvent(context, e); + condition.OnAcknowledge = OnAlarmAcknowledge; // Add HasCondition reference from source to condition if (sourceVariable != null) @@ -388,15 +401,19 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa AddPredefinedNode(SystemContext, condition); var baseTagRef = alarmAttr.FullTagReference.TrimEnd('[', ']'); - _alarmInAlarmTags[inAlarmTagRef] = new AlarmInfo + var alarmInfo = new AlarmInfo { SourceTagReference = alarmAttr.FullTagReference, SourceNodeId = sourceNodeId, SourceName = alarmAttr.AttributeName, ConditionNode = condition, PriorityTagReference = baseTagRef + ".Priority", - DescAttrNameTagReference = baseTagRef + ".DescAttrName" + DescAttrNameTagReference = baseTagRef + ".DescAttrName", + AckedTagReference = baseTagRef + ".Acked", + AckMsgTagReference = baseTagRef + ".AckMsg" }; + _alarmInAlarmTags[inAlarmTagRef] = alarmInfo; + _alarmAckedTags[alarmInfo.AckedTagReference] = alarmInfo; hasAlarms = true; } @@ -427,7 +444,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa foreach (var kvp in _alarmInAlarmTags) { // Subscribe to InAlarm, Priority, and DescAttrName for each alarm - var tagsToSubscribe = new[] { kvp.Key, kvp.Value.PriorityTagReference, kvp.Value.DescAttrNameTagReference }; + var tagsToSubscribe = new[] { kvp.Key, kvp.Value.PriorityTagReference, kvp.Value.DescAttrNameTagReference, kvp.Value.AckedTagReference }; foreach (var tag in tagsToSubscribe) { if (string.IsNullOrEmpty(tag) || !_tagToVariableNode.ContainsKey(tag)) @@ -444,6 +461,30 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } } + private ServiceResult OnAlarmAcknowledge( + ISystemContext context, ConditionState condition, byte[] eventId, LocalizedText comment) + { + var alarmInfo = _alarmInAlarmTags.Values + .FirstOrDefault(a => a.ConditionNode == condition); + if (alarmInfo == null) + return new ServiceResult(StatusCodes.BadNodeIdUnknown); + + try + { + var ackMessage = comment?.Text ?? ""; + _mxAccessClient.WriteAsync(alarmInfo.AckMsgTagReference, ackMessage) + .GetAwaiter().GetResult(); + Log.Information("Alarm acknowledge sent: {Source} (Message={AckMsg})", + alarmInfo.SourceName, ackMessage); + return ServiceResult.Good; + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to write AckMsg for {Source}", alarmInfo.SourceName); + return new ServiceResult(StatusCodes.BadInternalError); + } + } + private void ReportAlarmEvent(AlarmInfo info, bool active) { var condition = info.ConditionNode; @@ -455,6 +496,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa ? (!string.IsNullOrEmpty(info.CachedMessage) ? info.CachedMessage : $"Alarm active: {info.SourceName}") : $"Alarm cleared: {info.SourceName}"; + // Set a new EventId so clients can reference this event for acknowledge + condition.EventId.Value = Guid.NewGuid().ToByteArray(); + condition.SetActiveState(SystemContext, active); condition.Message.Value = new LocalizedText("en", message); condition.SetSeverity(SystemContext, (EventSeverity)severity); @@ -605,6 +649,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } } _alarmInAlarmTags.Remove(alarmKey); + if (!string.IsNullOrEmpty(info.AckedTagReference)) + _alarmAckedTags.Remove(info.AckedTagReference); } // Delete variable node @@ -796,6 +842,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa condition.SetSeverity(SystemContext, EventSeverity.Medium); condition.Retain.Value = false; condition.OnReportEvent = (context, n, e) => Server.ReportEvent(context, e); + condition.OnAcknowledge = OnAlarmAcknowledge; if (sourceVariable != null) { @@ -806,15 +853,19 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa AddPredefinedNode(SystemContext, condition); var baseTagRef = alarmAttr.FullTagReference.TrimEnd('[', ']'); - _alarmInAlarmTags[inAlarmTagRef] = new AlarmInfo + var alarmInfo = new AlarmInfo { SourceTagReference = alarmAttr.FullTagReference, SourceNodeId = sourceNodeId, SourceName = alarmAttr.AttributeName, ConditionNode = condition, PriorityTagReference = baseTagRef + ".Priority", - DescAttrNameTagReference = baseTagRef + ".DescAttrName" + DescAttrNameTagReference = baseTagRef + ".DescAttrName", + AckedTagReference = baseTagRef + ".Acked", + AckMsgTagReference = baseTagRef + ".AckMsg" }; + _alarmInAlarmTags[inAlarmTagRef] = alarmInfo; + _alarmAckedTags[alarmInfo.AckedTagReference] = alarmInfo; hasAlarms = true; } @@ -1532,6 +1583,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa // Prepare updates outside the Lock. Shared-state lookups stay inside the Lock. var updates = new List<(string address, BaseDataVariableState variable, DataValue dataValue)>(keys.Count); var pendingAlarmEvents = new List<(string address, AlarmInfo info, bool active, ushort? severity, string? message)>(); + var pendingAckedEvents = new List<(AlarmInfo info, bool acked)>(); foreach (var address in keys) { @@ -1539,7 +1591,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa continue; AlarmInfo? alarmInfo = null; + AlarmInfo? ackedAlarmInfo = null; bool newInAlarm = false; + bool newAcked = false; lock (Lock) { @@ -1562,6 +1616,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa if (newInAlarm == alarmInfo.LastInAlarm) alarmInfo = null; } + + // Check for Acked transitions + if (_alarmAckedTags.TryGetValue(address, out ackedAlarmInfo)) + { + newAcked = vtq.Value is true || vtq.Value is 1 || (vtq.Value is int ackedIntVal && ackedIntVal != 0); + pendingAckedEvents.Add((ackedAlarmInfo, newAcked)); + ackedAlarmInfo = null; // handled + } } if (alarmInfo == null) @@ -1601,7 +1663,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa } // Apply under Lock so ClearChangeMasks propagates to monitored items. - if (updates.Count > 0 || pendingAlarmEvents.Count > 0) + if (updates.Count > 0 || pendingAlarmEvents.Count > 0 || pendingAckedEvents.Count > 0) { lock (Lock) { @@ -1639,6 +1701,30 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa Log.Warning(ex, "Error reporting alarm event for {Source}", currentInfo.SourceName); } } + + // Apply Acked state changes + foreach (var (info, acked) in pendingAckedEvents) + { + var condition = info.ConditionNode; + if (condition == null) continue; + + try + { + condition.SetAcknowledgedState(SystemContext, acked); + condition.Retain.Value = (condition.ActiveState?.Id?.Value == true) || !acked; + + if (_tagToVariableNode.TryGetValue(info.SourceTagReference, out var src) && src.Parent != null) + src.Parent.ReportEvent(SystemContext, condition); + Server.ReportEvent(SystemContext, condition); + + Log.Information("Alarm {AckState}: {Source}", + acked ? "ACKNOWLEDGED" : "UNACKNOWLEDGED", info.SourceName); + } + catch (Exception ex) + { + Log.Warning(ex, "Error updating acked state for {Source}", info.SourceName); + } + } } }