diff --git a/docs/security.md b/docs/security.md index 4b7d055e..e6e16dba 100644 --- a/docs/security.md +++ b/docs/security.md @@ -327,9 +327,25 @@ The rule is intentionally scoped to async surfaces — pure in-memory accessors --- +## Write-Outcome Self-Correction + +When an OPC UA client writes a value and the driver reports a failure, the server automatically +reverts the variable node to its prior value and surfaces the Bad-quality status code to the client. +This prevents the node from showing a phantom Good value after a device-level write error. The +revert is local to the node that received the write — no cluster coordination is required. + +Additionally, an `AuditWriteUpdateEvent` is emitted on every write attempt (success or failure), +consistent with OPC UA Part 4 audit requirements. The event carries the source node id, the +requested value, and the final status code so audit log consumers can trace write outcomes without +polling the node. + +(Implemented in master `1d797c1c`.) + +--- + ## Audit Logging -- **Server**: authentication, certificate-validation, and write-denial events are logged through the regular Serilog rolling file sink. +- **Server**: authentication, certificate-validation, write, and write-denial events are logged through the regular Serilog rolling file sink; write outcomes also emit an `AuditWriteUpdateEvent` (see [Write-Outcome Self-Correction](#write-outcome-self-correction)). - **Admin**: `AuditWriterActor` (`src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Audit/AuditWriterActor.cs`) writes `ConfigAuditLog` rows (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigAuditLog.cs`) to the Config DB for publish, rollback, cluster-node CRUD, and credential rotation. Visible on the cluster Audit page (`ClusterAudit.razor`) for operators with `Viewer` or above. --- diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterRoleInfo.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterRoleInfo.cs index 512be105..e571f6c8 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterRoleInfo.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterRoleInfo.cs @@ -191,7 +191,9 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable { Receive(e => owner.HandleMemberEvent(e)); Receive(e => owner.HandleRoleLeaderChanged(e)); - Receive(_ => { /* no-op for now; reserved for ServiceLevel calc */ }); + // LeaderChanged is intentionally a no-op here: ServiceLevel lives in RedundancyStateActor + // (admin-role singleton) and has no consumer on this ClusterRoleInfo path by design. + Receive(_ => { }); Receive(_ => { /* seeded from initial snapshot */ }); } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshotFactory.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshotFactory.cs index 1b5e019d..cddee7af 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshotFactory.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshotFactory.cs @@ -31,6 +31,9 @@ public static class DraftSnapshotFactory public static async Task FromConfigDbAsync(OtOpcUaConfigDbContext db, CancellationToken ct = default) => new DraftSnapshot { + // GenerationId=0 and null Enterprise/Site are intentional conservative fallbacks at the + // global-factory level: the path-length validator uses its upper bound, and cross-gen + // EquipmentUuid checks run in separate validator passes — no rule here reads these fields. GenerationId = 0, // generation model dropped; placeholder (no rule reads it) ClusterId = string.Empty, // global snapshot; rules compare entity ClusterId fields, not this Namespaces = await db.Namespaces.AsNoTracking().ToListAsync(ct), @@ -42,7 +45,7 @@ public static class DraftSnapshotFactory Tags = await db.Tags.AsNoTracking().ToListAsync(ct), VirtualTags = await db.VirtualTags.AsNoTracking().ToListAsync(ct), PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct), - PriorEquipment = [], + PriorEquipment = [], // intentional: no prior-generation table to diff against at this level ActiveReservations = await db.ExternalIdReservations .AsNoTracking() .Where(r => r.ReleasedAt == null) // active only — matches DraftSnapshot.ActiveReservations semantics