Fix alarm acknowledge EventId validation and add auth plan
Set a new EventId (GUID) on AlarmConditionState each time an alarm event is reported so the framework can match it when clients call Acknowledge. Without this, the framework rejected all ack attempts with BadEventIdUnknown. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
231
auth_update.md
Normal file
231
auth_update.md
Normal file
@@ -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<UserCredential> 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<string, string> _users;
|
||||
|
||||
public ConfigUserAuthenticationProvider(List<UserCredential> users)
|
||||
{
|
||||
_users = users.ToDictionary(u => u.Username, u => u.Password, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public bool ValidateCredentials(string username, string password)
|
||||
{
|
||||
return _users.TryGetValue(username, out var expected) && expected == password;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 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)
|
||||
@@ -72,6 +72,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
|
||||
// Alarm tracking: maps InAlarm tag reference → alarm source info
|
||||
private readonly Dictionary<string, AlarmInfo> _alarmInAlarmTags = new Dictionary<string, AlarmInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, AlarmInfo> _alarmAckedTags = new Dictionary<string, AlarmInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Incremental sync: persistent node map and reverse lookup
|
||||
private readonly Dictionary<int, NodeState> _nodeMap = new Dictionary<int, NodeState>();
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
public string CachedMessage { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Galaxy tag reference for the alarm acknowledged state.
|
||||
/// </summary>
|
||||
public string AckedTagReference { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Galaxy tag reference for the acknowledge message that triggers acknowledgment.
|
||||
/// </summary>
|
||||
public string AckMsgTagReference { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user